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