| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2016-2021 Cinc |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| 7 | # you should have received as part of this distribution. |
|---|
| 8 | # |
|---|
| 9 | # Author: Cinc |
|---|
| 10 | # |
|---|
| 11 | |
|---|
| 12 | from collections import defaultdict, namedtuple |
|---|
| 13 | from datetime import datetime |
|---|
| 14 | from trac.core import Component, implements, TracError |
|---|
| 15 | from trac.db import Table, Column, Index |
|---|
| 16 | from trac.env import IEnvironmentSetupParticipant |
|---|
| 17 | from trac.search.api import shorten_result |
|---|
| 18 | from trac.util.datefmt import from_utimestamp, to_utimestamp, utc |
|---|
| 19 | from trac.util.translation import N_, _ |
|---|
| 20 | from .compat import itervalues |
|---|
| 21 | from .tracgenericclass.model import IConcreteClassProvider, AbstractVariableFieldsObject, \ |
|---|
| 22 | need_db_create_for_realm, create_db_for_realm, need_db_upgrade_for_realm, upgrade_db_for_realm |
|---|
| 23 | |
|---|
| 24 | __author__ = 'Cinc' |
|---|
| 25 | |
|---|
| 26 | |
|---|
| 27 | db_name_old = 'codereview_version' # for database version 1 |
|---|
| 28 | db_name = 'peerreview_version' |
|---|
| 29 | db_version = 2 # Don't change this one! |
|---|
| 30 | |
|---|
| 31 | datetime_now = datetime.now |
|---|
| 32 | |
|---|
| 33 | |
|---|
| 34 | class PeerReviewModel(AbstractVariableFieldsObject): |
|---|
| 35 | # Fields that have no default, and must not be modified directly by the user |
|---|
| 36 | protected_fields = ('review_id', 'res_realm', 'state') |
|---|
| 37 | |
|---|
| 38 | def __init__(self, env, id_=None, res_realm=None, state='new', db=None): |
|---|
| 39 | self.values = {} |
|---|
| 40 | self.env = env |
|---|
| 41 | |
|---|
| 42 | self.values['review_id'] = id_ |
|---|
| 43 | self.values['res_realm'] = 'peerreview' |
|---|
| 44 | # Set defaults |
|---|
| 45 | self.values['state'] = state |
|---|
| 46 | self.values['status'] = state |
|---|
| 47 | self.values['created'] = to_utimestamp(datetime_now(utc)) |
|---|
| 48 | self.values['parent_id'] = 0 |
|---|
| 49 | |
|---|
| 50 | key = self.build_key_object() |
|---|
| 51 | AbstractVariableFieldsObject.__init__(self, env, 'peerreview', key, db) |
|---|
| 52 | |
|---|
| 53 | def get_key_prop_names(self): |
|---|
| 54 | # Set the key used as ID when getting an object from the database |
|---|
| 55 | # If provided several ones they will all be used in the query: |
|---|
| 56 | # SELECT foo FROM bar WHERE key1 = .. AND key2 = .. AND ... |
|---|
| 57 | return ['review_id'] |
|---|
| 58 | |
|---|
| 59 | def clear_props(self): |
|---|
| 60 | for key in self.values: |
|---|
| 61 | if key not in ['review_id', 'res_realm']: |
|---|
| 62 | self.values[key] = None |
|---|
| 63 | |
|---|
| 64 | def create_instance(self, key): |
|---|
| 65 | """Create an instance which is identified by the values in dict 'key' |
|---|
| 66 | |
|---|
| 67 | @param key: dict with key: identifier 'review_id', val: actual value from the database. |
|---|
| 68 | |
|---|
| 69 | Note: while it's technically possible to have several identifiers uniquely describing an |
|---|
| 70 | object here, this class only use a single one. |
|---|
| 71 | """ |
|---|
| 72 | return PeerReviewModel(self.env, key['review_id'], 'peerreview') |
|---|
| 73 | |
|---|
| 74 | def change_status(self, new_status, author=None): |
|---|
| 75 | """Called from the change object listener to change state of review and connected files. |
|---|
| 76 | |
|---|
| 77 | Note that files are only set to closed when the new status is one of |
|---|
| 78 | |
|---|
| 79 | [peerreview] |
|---|
| 80 | terminal_review_states = ... |
|---|
| 81 | |
|---|
| 82 | as set in trac.ini. For any other status the file status is set to 'new'. |
|---|
| 83 | |
|---|
| 84 | @param new_status: new status for this review |
|---|
| 85 | @param author: user causing this change |
|---|
| 86 | @return: |
|---|
| 87 | """ |
|---|
| 88 | finish_states = self.env.config.getlist("peerreview", "terminal_review_states") |
|---|
| 89 | if new_status in finish_states: |
|---|
| 90 | self['closed'] = to_utimestamp(datetime_now(utc)) |
|---|
| 91 | else: |
|---|
| 92 | self['closed'] = None |
|---|
| 93 | self['status'] = new_status |
|---|
| 94 | self.save_changes(author=author) |
|---|
| 95 | |
|---|
| 96 | # Handle changeset reviews |
|---|
| 97 | |
|---|
| 98 | dm = ReviewDataModel(self.env) |
|---|
| 99 | dm['type'] = 'changeset' |
|---|
| 100 | dm['review_id'] = self['review_id'] |
|---|
| 101 | rev_data = list(dm.list_matching_objects()) |
|---|
| 102 | if rev_data: |
|---|
| 103 | changeset = rev_data[-1] |
|---|
| 104 | if self['status'] == 'closed': |
|---|
| 105 | # Mark changeset review as closed. 'data' will be something like: reponame:xxxxx:closed |
|---|
| 106 | changeset['data'] += u':closed' |
|---|
| 107 | else: |
|---|
| 108 | # User reopened the review |
|---|
| 109 | if changeset['data'].endswith(u':closed'): |
|---|
| 110 | changeset['data'] = changeset['data'][:-7] |
|---|
| 111 | changeset.save_changes(author) |
|---|
| 112 | |
|---|
| 113 | # Change the status of files attached to this review |
|---|
| 114 | |
|---|
| 115 | r_tmpl = ReviewFileModel(self.env) |
|---|
| 116 | r_tmpl.clear_props() |
|---|
| 117 | r_tmpl['review_id'] = self['review_id'] |
|---|
| 118 | all_files = r_tmpl.list_matching_objects() # This is a generator |
|---|
| 119 | # We only mark files for terminal states |
|---|
| 120 | if new_status in finish_states: |
|---|
| 121 | status = new_status |
|---|
| 122 | self.env.log.debug("PeerReviewModel: changing status of attached files for review '#%s'to '%s'" % |
|---|
| 123 | (self['review_id'], new_status)) |
|---|
| 124 | else: |
|---|
| 125 | status = 'new' |
|---|
| 126 | self.env.log.debug("PeerReviewModel: changing status of attached files for review '#%s'to '%s'" % |
|---|
| 127 | (self['review_id'], status)) |
|---|
| 128 | for f in all_files: |
|---|
| 129 | f['status'] = status |
|---|
| 130 | f.save_changes(author, "Status of review '#%s' changed." % self['review_id']) |
|---|
| 131 | |
|---|
| 132 | @classmethod |
|---|
| 133 | def reviews_by_period(cls, env, start_timestamp, end_timestamp): |
|---|
| 134 | """Used for getting timeline reviews. |
|---|
| 135 | |
|---|
| 136 | Times are compared using '>=' and '<='. |
|---|
| 137 | |
|---|
| 138 | @param env: Trac Environment object |
|---|
| 139 | @param start_timestamp: start time as a utimestamp |
|---|
| 140 | @param end_timestamp: end time as a utimestamp |
|---|
| 141 | @return: list of PeerReviewModels matching the times ordered by field 'created' |
|---|
| 142 | """ |
|---|
| 143 | reviews = [] |
|---|
| 144 | with env.db_query as db: |
|---|
| 145 | for row in db("SELECT review_id FROM peerreview WHERE created >= %s AND created <= %s ORDER BY created", |
|---|
| 146 | (start_timestamp, end_timestamp)): |
|---|
| 147 | reviews.append(cls(env, row[0])) |
|---|
| 148 | return reviews |
|---|
| 149 | |
|---|
| 150 | @classmethod |
|---|
| 151 | def select_all_reviews(cls, env): |
|---|
| 152 | with env.db_query as db: |
|---|
| 153 | for row in db("SELECT review_id FROM peerreview"): |
|---|
| 154 | yield cls(env, row[0]) |
|---|
| 155 | |
|---|
| 156 | def get_search_results(self, req, terms, filters): |
|---|
| 157 | results = {} |
|---|
| 158 | set_lst = [] |
|---|
| 159 | review = PeerReviewModel(self.env) |
|---|
| 160 | if "peerreview:all" in terms: |
|---|
| 161 | # list all codereviews if no search term is given. This is the behaviour of the old |
|---|
| 162 | # codereview search page. |
|---|
| 163 | self.env.log.info("SEARCH terms: %s 2", terms) |
|---|
| 164 | |
|---|
| 165 | for rev in PeerReviewModel.select_all_reviews(self.env): |
|---|
| 166 | title = u"Review #%s - %s: %s" % (rev['review_id'], rev['status'], rev['name']) |
|---|
| 167 | yield (req.href.peerreviewview(rev['review_id']), |
|---|
| 168 | title, |
|---|
| 169 | from_utimestamp(rev['created']), |
|---|
| 170 | rev['owner'], |
|---|
| 171 | shorten_result(rev['notes'])) |
|---|
| 172 | return |
|---|
| 173 | |
|---|
| 174 | for term in terms: |
|---|
| 175 | seen_reviews = [] |
|---|
| 176 | for field in ('name', 'notes', 'owner', 'status'): |
|---|
| 177 | review.clear_props() |
|---|
| 178 | review[field] = "%" + term + "%" |
|---|
| 179 | res = review.list_matching_objects(exact_match=False) |
|---|
| 180 | for rev in res: |
|---|
| 181 | seen_reviews.append(rev['review_id']) |
|---|
| 182 | if rev['review_id'] not in results: |
|---|
| 183 | results[rev['review_id']] = rev |
|---|
| 184 | set_lst.append(set(seen_reviews)) |
|---|
| 185 | |
|---|
| 186 | if results: |
|---|
| 187 | # Calculate common review_ids over all sets |
|---|
| 188 | res_set = set_lst[0] |
|---|
| 189 | for item in set_lst[1:]: |
|---|
| 190 | res_set &= item |
|---|
| 191 | |
|---|
| 192 | for rev_id in res_set: |
|---|
| 193 | rev = results[rev_id] |
|---|
| 194 | title = u"Review #%s - %s: %s" % (rev['review_id'], rev['status'], rev['name']) |
|---|
| 195 | yield (req.href.peerreviewview(rev['review_id']), |
|---|
| 196 | title, |
|---|
| 197 | from_utimestamp(rev['created']), |
|---|
| 198 | rev['owner'], |
|---|
| 199 | shorten_result(rev['notes'])) |
|---|
| 200 | |
|---|
| 201 | |
|---|
| 202 | class PeerReviewerModel(AbstractVariableFieldsObject): |
|---|
| 203 | # Fields that have no default, and must not be modified directly by the user |
|---|
| 204 | protected_fields = ('reviewer_id', 'res_realm', 'state') |
|---|
| 205 | |
|---|
| 206 | def __init__(self, env, id_=None, res_realm=None, state='new', db=None): |
|---|
| 207 | self.values = {} |
|---|
| 208 | |
|---|
| 209 | self.values['reviewer_id'] = id_ |
|---|
| 210 | self.values['res_realm'] = res_realm |
|---|
| 211 | self.values['state'] = state |
|---|
| 212 | self.values['status'] = state |
|---|
| 213 | |
|---|
| 214 | key = self.build_key_object() |
|---|
| 215 | AbstractVariableFieldsObject.__init__(self, env, 'peerreviewer', key, db) |
|---|
| 216 | |
|---|
| 217 | def get_key_prop_names(self): |
|---|
| 218 | return ['reviewer_id'] |
|---|
| 219 | |
|---|
| 220 | def clear_props(self): |
|---|
| 221 | for key in self.values: |
|---|
| 222 | if key not in ['reviewer_id', 'res_realm']: |
|---|
| 223 | self.values[key] = None |
|---|
| 224 | |
|---|
| 225 | def create_instance(self, key): |
|---|
| 226 | return PeerReviewerModel(self.env, key['reviewer_id'], 'peerreviewer') |
|---|
| 227 | |
|---|
| 228 | @staticmethod |
|---|
| 229 | def select_by_review_id(env, review_id): |
|---|
| 230 | """Get all reviewers associated with the review with the given id |
|---|
| 231 | |
|---|
| 232 | @param env: Trac Environment object |
|---|
| 233 | @param review_id: review id as int |
|---|
| 234 | @return: a generator returning PeerReviewerModels |
|---|
| 235 | """ |
|---|
| 236 | rm = PeerReviewerModel(env) |
|---|
| 237 | rm.clear_props() |
|---|
| 238 | rm['review_id'] = review_id |
|---|
| 239 | return rm.list_matching_objects() |
|---|
| 240 | |
|---|
| 241 | @classmethod |
|---|
| 242 | def delete_by_review_id_and_name(cls, env, review_id, name): |
|---|
| 243 | """Delete the reviewer 'name' from the review eith id 'review_id'. |
|---|
| 244 | |
|---|
| 245 | |
|---|
| 246 | @param env: Trac Environment object |
|---|
| 247 | @param review_id: id of the review as an int |
|---|
| 248 | @param name: name of the reviewer. |
|---|
| 249 | @return: None |
|---|
| 250 | """ |
|---|
| 251 | reviewer = cls(env) |
|---|
| 252 | reviewer['review_id'] = 1 |
|---|
| 253 | reviewer['reviewer'] = name |
|---|
| 254 | res = list(reviewer.list_matching_objects()) |
|---|
| 255 | if len(res) > 1: |
|---|
| 256 | raise ValueError("Found two reviewers with name '%s' for review '%s'." % (name, review_id)) |
|---|
| 257 | res[0].delete() |
|---|
| 258 | |
|---|
| 259 | |
|---|
| 260 | class ReviewFileModel(AbstractVariableFieldsObject): |
|---|
| 261 | # Fields that have no default, and must not be modified directly by the user |
|---|
| 262 | protected_fields = ('file_id', 'res_realm', 'state') |
|---|
| 263 | |
|---|
| 264 | def __init__(self, env, id_=None, res_realm=None, state='new', db=None): |
|---|
| 265 | self.values = {} |
|---|
| 266 | |
|---|
| 267 | if type(id_) is int: |
|---|
| 268 | id_ = str(id_) |
|---|
| 269 | self.values['file_id'] = id_ |
|---|
| 270 | self.values['res_realm'] = res_realm |
|---|
| 271 | self.values['state'] = state |
|---|
| 272 | self.values['status'] = state |
|---|
| 273 | |
|---|
| 274 | key = self.build_key_object() |
|---|
| 275 | AbstractVariableFieldsObject.__init__(self, env, 'peerreviewfile', key, db) |
|---|
| 276 | |
|---|
| 277 | def clear_props(self): |
|---|
| 278 | for key in self.values: |
|---|
| 279 | if key not in ['file_id', 'res_realm']: |
|---|
| 280 | self.values[key] = None |
|---|
| 281 | |
|---|
| 282 | def get_key_prop_names(self): |
|---|
| 283 | return ['file_id'] |
|---|
| 284 | |
|---|
| 285 | def create_instance(self, key): |
|---|
| 286 | return ReviewFileModel(self.env, key['file_id'], 'peerreviewfile') |
|---|
| 287 | |
|---|
| 288 | @classmethod |
|---|
| 289 | def file_dict_by_review(cls, env): |
|---|
| 290 | """Return a dict with review_id as key (int) and a file list as value. |
|---|
| 291 | |
|---|
| 292 | @return dict: key: review id as int, val: list of ReviewFileModels |
|---|
| 293 | |
|---|
| 294 | Note that files with review_id == 0 are omitted here. These are files |
|---|
| 295 | which are members of file lists belonging to projects. |
|---|
| 296 | """ |
|---|
| 297 | files_dict = defaultdict(list) |
|---|
| 298 | with env.db_query as db: |
|---|
| 299 | for row in db("SELECT file_id, review_id FROM peerreviewfile WHERE review_id != 0 ORDER BY review_id"): |
|---|
| 300 | file_ = cls(env, row[0]) |
|---|
| 301 | files_dict[row[1]].append(file_) |
|---|
| 302 | return files_dict |
|---|
| 303 | |
|---|
| 304 | @staticmethod |
|---|
| 305 | def delete_files_by_project_name(env, proj_name): |
|---|
| 306 | """Delete all file information belonging to project proj_name. |
|---|
| 307 | |
|---|
| 308 | @param env: Trac environment object |
|---|
| 309 | @param proj_name: name of project. Used to filter by 'project' column |
|---|
| 310 | @return: None |
|---|
| 311 | |
|---|
| 312 | It's possible to have a list of all files belonging to a project. Using this |
|---|
| 313 | list one may check which files are not yet reviewed. |
|---|
| 314 | These files are those in the table which have data in the 'project' column. |
|---|
| 315 | The review_id is set to 0. |
|---|
| 316 | """ |
|---|
| 317 | with env.db_transaction as db: |
|---|
| 318 | db("DELETE FROM peerreviewfile WHERE project=%s", (proj_name,)) |
|---|
| 319 | |
|---|
| 320 | @staticmethod |
|---|
| 321 | def select_by_review(env, review_id): |
|---|
| 322 | """Get all file objects for a given review_id. |
|---|
| 323 | @param env: Trac Environment object |
|---|
| 324 | @param review_id: id of a review as int |
|---|
| 325 | @return: Returns a generator. |
|---|
| 326 | |
|---|
| 327 | Note that review_id 0 is allowed here to query files belonging to |
|---|
| 328 | project file lists. |
|---|
| 329 | """ |
|---|
| 330 | rf = ReviewFileModel(env) |
|---|
| 331 | rf.clear_props() |
|---|
| 332 | rf['review_id'] = review_id |
|---|
| 333 | return rf.list_matching_objects() |
|---|
| 334 | |
|---|
| 335 | |
|---|
| 336 | class ReviewDataModel(AbstractVariableFieldsObject): |
|---|
| 337 | """Data model holding whatever you want to create relations for.""" |
|---|
| 338 | # Fields that have no default, and must not be modified directly by the user |
|---|
| 339 | protected_fields = ('data_id', 'res_realm', 'state') |
|---|
| 340 | |
|---|
| 341 | def __init__(self, env, id_=None, res_realm=None, state='new', db=None): |
|---|
| 342 | self.values = {} |
|---|
| 343 | |
|---|
| 344 | self.values['data_id'] = id_ |
|---|
| 345 | self.values['res_realm'] = res_realm |
|---|
| 346 | self.values['state'] = state |
|---|
| 347 | |
|---|
| 348 | key = self.build_key_object() |
|---|
| 349 | AbstractVariableFieldsObject.__init__(self, env, 'peerreviewdata', key, db) |
|---|
| 350 | |
|---|
| 351 | def get_key_prop_names(self): |
|---|
| 352 | return ['data_id'] |
|---|
| 353 | |
|---|
| 354 | def clear_props(self): |
|---|
| 355 | for key in self.values: |
|---|
| 356 | if key not in ['data_id', 'res_realm']: |
|---|
| 357 | self.values[key] = None |
|---|
| 358 | |
|---|
| 359 | def create_instance(self, key): |
|---|
| 360 | return ReviewDataModel(self.env, key['data_id'], 'peerreviewdata') |
|---|
| 361 | |
|---|
| 362 | @classmethod |
|---|
| 363 | def comments_for_file_and_owner(cls, env, file_id, owner): |
|---|
| 364 | """Return a list of data.""" |
|---|
| 365 | |
|---|
| 366 | with env.db_query as db: |
|---|
| 367 | cursor = db.cursor() |
|---|
| 368 | cursor.execute("SELECT comment_id, type, data FROM peerreviewdata " |
|---|
| 369 | "WHERE file_id = %s AND owner = %s", |
|---|
| 370 | (file_id, owner)) |
|---|
| 371 | return cursor.fetchall() |
|---|
| 372 | |
|---|
| 373 | @classmethod |
|---|
| 374 | def comments_for_owner(cls, env, owner): |
|---|
| 375 | """Return a list of comment data for owner. |
|---|
| 376 | """ |
|---|
| 377 | with env.db_query as db: |
|---|
| 378 | cursor = db.cursor() |
|---|
| 379 | cursor.execute("SELECT comment_id, review_id, type, data FROM peerreviewdata " |
|---|
| 380 | "WHERE owner = %s", (owner,)) |
|---|
| 381 | return cursor.fetchall() |
|---|
| 382 | |
|---|
| 383 | @classmethod |
|---|
| 384 | def all_file_project_data(cls, env): |
|---|
| 385 | """Return a dict with project name as key and a dict with project information as value.""" |
|---|
| 386 | fileprojectname, datatype, data = range(3) |
|---|
| 387 | sql = """SELECT n.data AS name , r.type, r.data FROM peerreviewdata AS n |
|---|
| 388 | JOIN peerreviewdata AS r ON r.data_key = n.data |
|---|
| 389 | WHERE n.type = 'fileproject'""" |
|---|
| 390 | with env.db_query as db: |
|---|
| 391 | cursor = db.cursor() |
|---|
| 392 | cursor.execute(sql) |
|---|
| 393 | files_dict = defaultdict(dict) |
|---|
| 394 | for row in cursor: |
|---|
| 395 | files_dict[row[fileprojectname]][row[datatype]] = row[data] |
|---|
| 396 | return files_dict |
|---|
| 397 | |
|---|
| 398 | |
|---|
| 399 | class ReviewCommentModel(AbstractVariableFieldsObject): |
|---|
| 400 | """Data model holding whatever you want to create relations for.""" |
|---|
| 401 | # Fields that have no default, and must not be modified directly by the user |
|---|
| 402 | protected_fields = ('comment_id', 'res_realm', 'state') |
|---|
| 403 | |
|---|
| 404 | def __init__(self, env, id_=None, res_realm=None, state='new', db=None): |
|---|
| 405 | self.values = {} |
|---|
| 406 | |
|---|
| 407 | self.values['comment_id'] = id_ |
|---|
| 408 | self.values['res_realm'] = res_realm |
|---|
| 409 | self.values['state'] = state |
|---|
| 410 | self.values['created'] = to_utimestamp(datetime_now(utc)) |
|---|
| 411 | self.children = {} |
|---|
| 412 | |
|---|
| 413 | key = self.build_key_object() |
|---|
| 414 | AbstractVariableFieldsObject.__init__(self, env, 'peerreviewcomment', key, db) |
|---|
| 415 | |
|---|
| 416 | def get_key_prop_names(self): |
|---|
| 417 | return ['comment_id'] |
|---|
| 418 | |
|---|
| 419 | def clear_props(self): |
|---|
| 420 | for key in self.values: |
|---|
| 421 | if key not in ['comment_id', 'res_realm']: |
|---|
| 422 | self.values[key] = None |
|---|
| 423 | |
|---|
| 424 | def create_instance(self, key): |
|---|
| 425 | return ReviewCommentModel(self.env, key['comment_id'], 'peerreviewcomment') |
|---|
| 426 | |
|---|
| 427 | @staticmethod |
|---|
| 428 | def comment_ids_by_file_id(env): |
|---|
| 429 | """Return a dict with file_id as key and a comment id list as value. |
|---|
| 430 | |
|---|
| 431 | @param env: Trac Environment object |
|---|
| 432 | @return dict with key: file id as int, val: list of comment ids for that file as int |
|---|
| 433 | """ |
|---|
| 434 | the_dict = defaultdict(list) |
|---|
| 435 | for row in env.db_query("SELECT comment_id, file_id FROM peerreviewcomment"): |
|---|
| 436 | the_dict[row[1]].append(row[0]) |
|---|
| 437 | return the_dict |
|---|
| 438 | |
|---|
| 439 | @staticmethod |
|---|
| 440 | def select_by_file_id(env, file_id): |
|---|
| 441 | """Return all comments for the file specified by 'file_id'. |
|---|
| 442 | |
|---|
| 443 | :param env: Trac Environment object |
|---|
| 444 | :param file_id: file id as int. All comments for this file are returned |
|---|
| 445 | :return: generator for ReviewCommentModels |
|---|
| 446 | """ |
|---|
| 447 | rcm = ReviewCommentModel(env) |
|---|
| 448 | rcm.clear_props() |
|---|
| 449 | rcm['file_id'] = file_id |
|---|
| 450 | return rcm.list_matching_objects() |
|---|
| 451 | |
|---|
| 452 | @staticmethod |
|---|
| 453 | def create_comment_tree(env, fileid, line): |
|---|
| 454 | """Create a comment tree for the given file and line number. |
|---|
| 455 | |
|---|
| 456 | :param env: Trac environment object |
|---|
| 457 | :param fileid: id of a peerreviewfile |
|---|
| 458 | :param line: line number we wnat to get comments for |
|---|
| 459 | :return dict with key: comment id, val: comment data as a namedtuple |
|---|
| 460 | each comments 'children' dict is properly populated thus for each |
|---|
| 461 | comment we have a (sub)tree. Comments with parent_id = -1 are root |
|---|
| 462 | comments. |
|---|
| 463 | """ |
|---|
| 464 | Comment = namedtuple('Comment', "children, comment_id, file_id, parent_id, line, author, comment, created") |
|---|
| 465 | tree = {} |
|---|
| 466 | for row in env.db_query("SELECT comment_id, file_id, parent_id, line_num, author, comment, created" |
|---|
| 467 | " FROM peerreviewcomment WHERE file_id = %s" |
|---|
| 468 | " AND line_num = %s" |
|---|
| 469 | " ORDER by created", (fileid, line)): |
|---|
| 470 | comment = Comment({}, *row) |
|---|
| 471 | tree[comment.comment_id] = comment |
|---|
| 472 | for comment in itervalues(tree): |
|---|
| 473 | if comment.parent_id != -1 and comment.parent_id in tree: |
|---|
| 474 | tree[comment.parent_id].children[comment.comment_id] = comment |
|---|
| 475 | return tree |
|---|
| 476 | |
|---|
| 477 | |
|---|
| 478 | class PeerReviewModelProvider(Component): |
|---|
| 479 | """This class provides the data model for the generic workflow plugin. |
|---|
| 480 | |
|---|
| 481 | [[BR]] |
|---|
| 482 | The actual data model on the db is created starting from the |
|---|
| 483 | SCHEMA declaration below. |
|---|
| 484 | For each table, we specify whether to create also a '_custom' and |
|---|
| 485 | a '_change' table. |
|---|
| 486 | |
|---|
| 487 | This class also provides the specification of the available fields |
|---|
| 488 | for each class, being them standard fields and the custom fields |
|---|
| 489 | specified in the trac.ini file. |
|---|
| 490 | The custom field specification follows the same syntax as for |
|---|
| 491 | Tickets. |
|---|
| 492 | Currently, only 'text' type of custom fields are supported. |
|---|
| 493 | """ |
|---|
| 494 | |
|---|
| 495 | implements(IConcreteClassProvider, IEnvironmentSetupParticipant) |
|---|
| 496 | |
|---|
| 497 | current_db_version = 0 |
|---|
| 498 | |
|---|
| 499 | SCHEMA = { |
|---|
| 500 | 'peerreview': |
|---|
| 501 | {'table': |
|---|
| 502 | Table('peerreview', key=('review_id'))[ |
|---|
| 503 | Column('review_id', auto_increment=True, type='int'), |
|---|
| 504 | Column('owner'), |
|---|
| 505 | Column('status'), |
|---|
| 506 | Column('created', type='int'), |
|---|
| 507 | Column('closed', type='int'), |
|---|
| 508 | Column('name'), |
|---|
| 509 | Column('notes'), |
|---|
| 510 | Column('parent_id', type='int'), |
|---|
| 511 | Column('project'), |
|---|
| 512 | Column('keywords'), |
|---|
| 513 | Index(['owner']), |
|---|
| 514 | Index(['status'])], |
|---|
| 515 | 'has_custom': True, |
|---|
| 516 | 'has_change': True, |
|---|
| 517 | 'version': 6}, |
|---|
| 518 | 'peerreviewfile': |
|---|
| 519 | {'table': |
|---|
| 520 | Table('peerreviewfile', key=('file_id'))[ |
|---|
| 521 | Column('file_id', auto_increment=True, type='int'), |
|---|
| 522 | Column('review_id', type='int'), |
|---|
| 523 | Column('path'), |
|---|
| 524 | Column('line_start', type='int'), |
|---|
| 525 | Column('line_end', type='int'), |
|---|
| 526 | Column('repo'), |
|---|
| 527 | Column('revision'), |
|---|
| 528 | Column('changerevision'), |
|---|
| 529 | Column('hash'), |
|---|
| 530 | Column('status'), |
|---|
| 531 | Column('project'), |
|---|
| 532 | Index(['hash']), |
|---|
| 533 | Index(['review_id']), |
|---|
| 534 | Index(['status']), |
|---|
| 535 | Index(['project']) |
|---|
| 536 | ], |
|---|
| 537 | 'has_custom': True, |
|---|
| 538 | 'has_change': True, |
|---|
| 539 | 'version': 5}, |
|---|
| 540 | 'peerreviewcomment': |
|---|
| 541 | {'table': |
|---|
| 542 | Table('peerreviewcomment', key=('comment_id'))[ |
|---|
| 543 | Column('comment_id', auto_increment=True, type='int'), |
|---|
| 544 | Column('file_id', type='int'), |
|---|
| 545 | Column('parent_id', type='int'), |
|---|
| 546 | Column('line_num', type='int'), |
|---|
| 547 | Column('author'), |
|---|
| 548 | Column('comment'), |
|---|
| 549 | Column('attachment_path'), |
|---|
| 550 | Column('created', type='int'), |
|---|
| 551 | Column('refs'), |
|---|
| 552 | Column('type'), |
|---|
| 553 | Column('status'), |
|---|
| 554 | Index(['file_id']), |
|---|
| 555 | Index(['author']) |
|---|
| 556 | ], |
|---|
| 557 | 'has_custom': True, |
|---|
| 558 | 'has_change': True, |
|---|
| 559 | 'version': 6}, |
|---|
| 560 | 'peerreviewer': |
|---|
| 561 | {'table': |
|---|
| 562 | Table('peerreviewer', key=('reviewer_id'))[ |
|---|
| 563 | Column('reviewer_id', auto_increment=True, type='int'), |
|---|
| 564 | Column('review_id', type='int'), |
|---|
| 565 | Column('reviewer'), |
|---|
| 566 | Column('status'), |
|---|
| 567 | Column('vote', type='int'), |
|---|
| 568 | Index(['reviewer']), |
|---|
| 569 | Index(['review_id']) |
|---|
| 570 | ], |
|---|
| 571 | 'has_custom': True, |
|---|
| 572 | 'has_change': True, |
|---|
| 573 | 'version': 5}, |
|---|
| 574 | 'peerreviewdata': |
|---|
| 575 | {'table': |
|---|
| 576 | Table('peerreviewdata', key=('data_id'))[ |
|---|
| 577 | Column('data_id', type='int'), |
|---|
| 578 | Column('review_id', type='int'), |
|---|
| 579 | Column('comment_id', type='int'), |
|---|
| 580 | Column('file_id', type='int'), |
|---|
| 581 | Column('reviewer_id', type='int'), |
|---|
| 582 | Column('type'), |
|---|
| 583 | Column('data'), |
|---|
| 584 | Column('owner'), |
|---|
| 585 | Column('data_key'), |
|---|
| 586 | Index(['review_id']), |
|---|
| 587 | Index(['comment_id']), |
|---|
| 588 | Index(['file_id']) |
|---|
| 589 | ], |
|---|
| 590 | 'has_custom': False, |
|---|
| 591 | 'has_change': False, |
|---|
| 592 | 'version': 3}, |
|---|
| 593 | } |
|---|
| 594 | |
|---|
| 595 | FIELDS = { |
|---|
| 596 | 'peerreview': [ |
|---|
| 597 | {'name': 'review_id', 'type': 'int', 'label': N_('Review ID')}, |
|---|
| 598 | {'name': 'owner', 'type': 'text', 'label': N_('Review owner')}, |
|---|
| 599 | {'name': 'status', 'type': 'text', 'label': N_('Review status')}, |
|---|
| 600 | {'name': 'created', 'type': 'int', 'label': N_('Review creation date')}, |
|---|
| 601 | {'name': 'closed', 'type': 'int', 'label': N_('Review closing date')}, |
|---|
| 602 | {'name': 'name', 'type': 'text', 'label': N_('Review name')}, |
|---|
| 603 | {'name': 'notes', 'type': 'text', 'label': N_('Review notes')}, |
|---|
| 604 | {'name': 'parent_id', 'type': 'int', 'label': N_('Review parent. 0 if not a followup review')}, |
|---|
| 605 | {'name': 'project', 'type': 'text', 'label': N_('Project')}, |
|---|
| 606 | {'name': 'keywords', 'type': 'text', 'label': N_('Review keywords')} |
|---|
| 607 | ], |
|---|
| 608 | 'peerreviewfile': [ |
|---|
| 609 | {'name': 'file_id', 'type': 'int', 'label': N_('File ID')}, |
|---|
| 610 | {'name': 'review_id', 'type': 'int', 'label': N_('Review ID')}, |
|---|
| 611 | {'name': 'path', 'type': 'text', 'label': N_('File path')}, |
|---|
| 612 | {'name': 'line_start', 'type': 'int', 'label': N_('First line to review')}, |
|---|
| 613 | {'name': 'line_end', 'type': 'int', 'label': N_('Last line to review')}, |
|---|
| 614 | {'name': 'repo', 'type': 'text', 'label': N_('Repository')}, |
|---|
| 615 | {'name': 'revision', 'type': 'text', 'label': N_('Revision')}, |
|---|
| 616 | {'name': 'changerevision', 'type': 'text', 'label': N_('Revision of last change')}, |
|---|
| 617 | {'name': 'hash', 'type': 'text', 'label': N_('Hash of file content')}, |
|---|
| 618 | {'name': 'status', 'type': 'text', 'label': N_('File status')}, |
|---|
| 619 | {'name': 'project', 'type': 'text', 'label': N_('Project')}, |
|---|
| 620 | ], |
|---|
| 621 | 'peerreviewcomment': [ |
|---|
| 622 | {'name': 'comment_id', 'type': 'int', 'label': N_('Comment ID')}, |
|---|
| 623 | {'name': 'file_id', 'type': 'int', 'label': N_('File ID')}, |
|---|
| 624 | {'name': 'parent_id', 'type': 'int', 'label': N_('Parent comment')}, |
|---|
| 625 | {'name': 'line_num', 'type': 'int', 'label': N_('Line')}, |
|---|
| 626 | {'name': 'author', 'type': 'text', 'label': N_('Author')}, |
|---|
| 627 | {'name': 'comment', 'type': 'text', 'label': N_('Comment')}, |
|---|
| 628 | {'name': 'attachment_path', 'type': 'text', 'label': N_('Attachment')}, |
|---|
| 629 | {'name': 'created', 'type': 'int', 'label': N_('Comment creation date')}, |
|---|
| 630 | {'name': 'status', 'type': 'text', 'label': N_('Comment status')} |
|---|
| 631 | ], |
|---|
| 632 | 'peerreviewer': [ |
|---|
| 633 | {'name': 'reviewer_id', 'type': 'int', 'label': N_('ID')}, |
|---|
| 634 | {'name': 'review_id', 'type': 'int', 'label': N_('Review ID')}, |
|---|
| 635 | {'name': 'reviewer', 'type': 'text', 'label': N_('Reviewer')}, |
|---|
| 636 | {'name': 'status', 'type': 'text', 'label': N_('Review status')}, |
|---|
| 637 | {'name': 'vote', 'type': 'int', 'label': N_('Vote')}, |
|---|
| 638 | ], |
|---|
| 639 | 'peerreviewdata': [ |
|---|
| 640 | {'name': 'data_id', 'type': 'int', 'label': N_('ID')}, |
|---|
| 641 | {'name': 'review_id', 'type': 'int', 'label': N_('Review ID')}, |
|---|
| 642 | {'name': 'comment_id', 'type': 'int', 'label': N_('Comment ID')}, |
|---|
| 643 | {'name': 'file_id', 'type': 'int', 'label': N_('File ID')}, |
|---|
| 644 | {'name': 'reviewer_id', 'type': 'int', 'label': N_('Reviewer ID')}, |
|---|
| 645 | {'name': 'type', 'type': 'text', 'label': N_('Type')}, |
|---|
| 646 | {'name': 'data', 'type': 'text', 'label': N_('Data')}, |
|---|
| 647 | {'name': 'owner', 'type': 'text', 'label': N_('Owner')}, |
|---|
| 648 | {'name': 'data_key', 'type': 'text', 'label': N_('Key for data')}, |
|---|
| 649 | ], |
|---|
| 650 | } |
|---|
| 651 | |
|---|
| 652 | METADATA = { |
|---|
| 653 | 'peerreview': { |
|---|
| 654 | 'label': "Review", |
|---|
| 655 | 'searchable': True, |
|---|
| 656 | 'has_custom': True, |
|---|
| 657 | 'has_change': True |
|---|
| 658 | }, |
|---|
| 659 | 'peerreviewfile': { |
|---|
| 660 | 'label': "ReviewFile", |
|---|
| 661 | 'searchable': False, |
|---|
| 662 | 'has_custom': True, |
|---|
| 663 | 'has_change': True |
|---|
| 664 | }, |
|---|
| 665 | 'peerreviewcomment': { |
|---|
| 666 | 'label': "ReviewComment", |
|---|
| 667 | 'searchable': True, |
|---|
| 668 | 'has_custom': True, |
|---|
| 669 | 'has_change': True |
|---|
| 670 | }, |
|---|
| 671 | 'peerreviewer': { |
|---|
| 672 | 'label': "Reviewer", |
|---|
| 673 | 'searchable': False, |
|---|
| 674 | 'has_custom': True, |
|---|
| 675 | 'has_change': True |
|---|
| 676 | }, |
|---|
| 677 | 'peerreviewdata': { |
|---|
| 678 | 'label': "Data", |
|---|
| 679 | 'searchable': True, |
|---|
| 680 | 'has_custom': False, |
|---|
| 681 | 'has_change': False |
|---|
| 682 | }, |
|---|
| 683 | } |
|---|
| 684 | |
|---|
| 685 | # IConcreteClassProvider methods |
|---|
| 686 | |
|---|
| 687 | def get_realms(self): |
|---|
| 688 | yield 'peerreview' |
|---|
| 689 | yield 'peerreviewer' |
|---|
| 690 | |
|---|
| 691 | def get_data_models(self): |
|---|
| 692 | return self.SCHEMA |
|---|
| 693 | |
|---|
| 694 | def get_fields(self): |
|---|
| 695 | return self.FIELDS |
|---|
| 696 | |
|---|
| 697 | def get_metadata(self): |
|---|
| 698 | return self.METADATA |
|---|
| 699 | |
|---|
| 700 | def create_instance(self, realm, key=None): |
|---|
| 701 | obj = None |
|---|
| 702 | |
|---|
| 703 | if realm == 'peerreview': |
|---|
| 704 | if key is not None: |
|---|
| 705 | obj = PeerReviewModel(self.env, key, realm) |
|---|
| 706 | else: |
|---|
| 707 | obj = PeerReviewModel(self.env) |
|---|
| 708 | elif realm == 'peerreviewer': |
|---|
| 709 | if key is not None: |
|---|
| 710 | obj = PeerReviewerModel(self.env, key, realm) |
|---|
| 711 | else: |
|---|
| 712 | obj = PeerReviewerModel(self.env) |
|---|
| 713 | return obj |
|---|
| 714 | |
|---|
| 715 | def check_permission(self, req, realm, key_str=None, operation='set', name=None, value=None): |
|---|
| 716 | pass |
|---|
| 717 | |
|---|
| 718 | # IEnvironmentSetupParticipant methods |
|---|
| 719 | def environment_created(self): |
|---|
| 720 | self.current_db_version = 0 |
|---|
| 721 | self.upgrade_environment() |
|---|
| 722 | |
|---|
| 723 | def environment_needs_upgrade(self, db_=None): |
|---|
| 724 | with self.env.db_query as db: |
|---|
| 725 | self.current_db_version = self._get_version(db.cursor()) |
|---|
| 726 | |
|---|
| 727 | if self.current_db_version < db_version: |
|---|
| 728 | self.log.info("PeerReview plugin database schema version is %d, should be %d", |
|---|
| 729 | self.current_db_version, db_version) |
|---|
| 730 | return True |
|---|
| 731 | |
|---|
| 732 | for realm in self.SCHEMA: |
|---|
| 733 | realm_metadata = self.SCHEMA[realm] |
|---|
| 734 | if need_db_create_for_realm(self.env, realm, realm_metadata, db) or \ |
|---|
| 735 | need_db_upgrade_for_realm(self.env, realm, realm_metadata, db): |
|---|
| 736 | return True |
|---|
| 737 | |
|---|
| 738 | return False |
|---|
| 739 | |
|---|
| 740 | def upgrade_environment(self, db=None): |
|---|
| 741 | # Create or update db. We are going step by step through all database versions. |
|---|
| 742 | |
|---|
| 743 | if self.current_db_version != 0 and self.current_db_version < 6: |
|---|
| 744 | raise TracError("Upgrade for database version %s not supported. Raise a ticket for " |
|---|
| 745 | "PeerReviewPlugin for a fix." |
|---|
| 746 | % self.current_db_version) |
|---|
| 747 | |
|---|
| 748 | self.upgrade_tracgeneric() |
|---|
| 749 | |
|---|
| 750 | def upgrade_tracgeneric(self): |
|---|
| 751 | """Upgrade for versions > 2 using the TracGenericClass mechanism.""" |
|---|
| 752 | with self.env.db_transaction as db: |
|---|
| 753 | for realm in self.SCHEMA: |
|---|
| 754 | realm_metadata = self.SCHEMA[realm] |
|---|
| 755 | |
|---|
| 756 | if need_db_create_for_realm(self.env, realm, realm_metadata, db): |
|---|
| 757 | create_db_for_realm(self.env, realm, realm_metadata, db) |
|---|
| 758 | self.add_workflows() |
|---|
| 759 | |
|---|
| 760 | elif need_db_upgrade_for_realm(self.env, realm, realm_metadata, db): |
|---|
| 761 | upgrade_db_for_realm(self.env, 'codereview.upgrades', realm, realm_metadata, db) |
|---|
| 762 | |
|---|
| 763 | def add_workflows(self): |
|---|
| 764 | |
|---|
| 765 | env = self.env |
|---|
| 766 | # Add default workflow for peerreview |
|---|
| 767 | |
|---|
| 768 | wf_data = [['approve', 'reviewed -> approved'], |
|---|
| 769 | ['approve.name', 'Approve the review'], |
|---|
| 770 | ['close', 'new, reviewed, in-review -> closed'], |
|---|
| 771 | ['close.name', 'Close review'], |
|---|
| 772 | ['disapprove', 'reviewed -> disapproved'], |
|---|
| 773 | ['disapprove.name', 'Deny this review'], |
|---|
| 774 | ['reopen', 'closed, reviewed, approved, disapproved -> new'], |
|---|
| 775 | ['reopen.permissions', 'CODE_REVIEW_MGR'], |
|---|
| 776 | ['review-done', 'in-review -> reviewed'], |
|---|
| 777 | ['review-done.name', 'Mark as reviewed'], |
|---|
| 778 | ['reviewing', 'new -> in-review'], |
|---|
| 779 | ['reviewing.default', '5'], |
|---|
| 780 | ['reviewing.name', 'Start review'], |
|---|
| 781 | ['change_owner', '* -> *'], |
|---|
| 782 | ['change_owner.name', 'Change Owner to'], |
|---|
| 783 | ['change_owner.operations', 'set_review_owner'], |
|---|
| 784 | ['change_owner.permissions', 'CODE_REVIEW_MGR'], |
|---|
| 785 | ['change_owner.default', '-1'], |
|---|
| 786 | ] |
|---|
| 787 | wf_section = 'peerreview-resource_workflow' |
|---|
| 788 | |
|---|
| 789 | if wf_section not in env.config.sections(): |
|---|
| 790 | env.log.info("Adding default workflow for 'peerreview' to config.") |
|---|
| 791 | for item in wf_data: |
|---|
| 792 | env.config.set(wf_section, item[0], item[1]) |
|---|
| 793 | env.config.save() |
|---|
| 794 | |
|---|
| 795 | # Add default workflow for peerreviewer |
|---|
| 796 | |
|---|
| 797 | wf_data = [['reviewing', 'new -> in-review'], |
|---|
| 798 | ['reviewing.name', 'Start review'], |
|---|
| 799 | ['review_done', 'in-review -> reviewed'], |
|---|
| 800 | ['review_done.name', 'Mark review as done.'], |
|---|
| 801 | ['reopen', 'in-review, reviewed -> new'], |
|---|
| 802 | ['reopen.name', "Reset review state to 'new'"], |
|---|
| 803 | ] |
|---|
| 804 | wf_section = 'peerreviewer-resource_workflow' |
|---|
| 805 | |
|---|
| 806 | if wf_section not in env.config.sections(): |
|---|
| 807 | env.log.info("Adding default workflow for 'peerreviewer' to config.") |
|---|
| 808 | for item in wf_data: |
|---|
| 809 | env.config.set(wf_section, item[0], item[1]) |
|---|
| 810 | env.config.save() |
|---|
| 811 | |
|---|
| 812 | def _get_version(self, cursor): |
|---|
| 813 | cursor.execute("SELECT value FROM system WHERE name = %s", (db_name,)) |
|---|
| 814 | value = cursor.fetchone() |
|---|
| 815 | val = int(value[0]) if value else 0 |
|---|
| 816 | return val |
|---|
| 817 | |
|---|
| 818 | |
|---|
| 819 | def get_users(env): |
|---|
| 820 | users = [] |
|---|
| 821 | with env.db_query as db: |
|---|
| 822 | cursor = db.cursor() |
|---|
| 823 | cursor.execute("""SELECT DISTINCT p1.username FROM permission AS p1 |
|---|
| 824 | LEFT JOIN permission AS p2 ON p1.action = p2.username |
|---|
| 825 | WHERE p2.action IN ('CODE_REVIEW_DEV', 'CODE_REVIEW_MGR') |
|---|
| 826 | OR p1.action IN ('CODE_REVIEW_DEV', 'CODE_REVIEW_MGR', 'TRAC_ADMIN') |
|---|
| 827 | """) |
|---|
| 828 | for row in cursor: |
|---|
| 829 | users.append(row[0]) |
|---|
| 830 | if users: |
|---|
| 831 | # Filter groups from the results. We should probably do this using the group provider component |
|---|
| 832 | cursor.execute("""Select DISTINCT p3.action FROM permission AS p3 |
|---|
| 833 | JOIN permission p4 ON p3.action = p4.username""") |
|---|
| 834 | groups = [] |
|---|
| 835 | for row in cursor: |
|---|
| 836 | groups.append(row[0]) |
|---|
| 837 | groups.append('authenticated') |
|---|
| 838 | users = list(set(users)-set(groups)) |
|---|
| 839 | return sorted(users) |
|---|
| 840 | |
|---|
| 841 | |
|---|
| 842 | # Deprecated use ReviewCommentModel instead |
|---|
| 843 | class Comment(object): |
|---|
| 844 | |
|---|
| 845 | def __init__(self, env, file_id=None): |
|---|
| 846 | self.env = env |
|---|
| 847 | self._init_from_row((None,)*8) |
|---|
| 848 | |
|---|
| 849 | def _init_from_row(self, row): |
|---|
| 850 | comment_id, file_id, parent_id, line_num, author, comment, attachment_path, created = row |
|---|
| 851 | self.comment_id = comment_id |
|---|
| 852 | self.file_id = file_id |
|---|
| 853 | self.parent_id = parent_id |
|---|
| 854 | self.line_num = line_num |
|---|
| 855 | self.author = author |
|---|
| 856 | self.comment = comment |
|---|
| 857 | self.attachment_path = attachment_path |
|---|
| 858 | self.created = created |
|---|
| 859 | |
|---|
| 860 | @classmethod |
|---|
| 861 | def select_by_file_id(cls, env, file_id): |
|---|
| 862 | with env.db_query as db: |
|---|
| 863 | cursor = db.cursor() |
|---|
| 864 | cursor.execute("SELECT comment_id, file_id, parent_id, line_num, author, comment, attachment_path, created FROM " |
|---|
| 865 | "peerreviewcomment WHERE file_id=%s ORDER BY line_num", (file_id,)) |
|---|
| 866 | comments = [] |
|---|
| 867 | for row in cursor: |
|---|
| 868 | c = cls(env) |
|---|
| 869 | c._init_from_row(row) |
|---|
| 870 | comments.append(c) |
|---|
| 871 | return comments |
|---|