##// END OF EJS Templates
db: use a wrapper on pull requests _last_merge_status to ensure this is always INT....
marcink -
r1968:ea1add97 default
parent child Browse files
Show More
@@ -1,4111 +1,4119 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Database Models for RhodeCode Enterprise
23 23 """
24 24
25 25 import re
26 26 import os
27 27 import time
28 28 import hashlib
29 29 import logging
30 30 import datetime
31 31 import warnings
32 32 import ipaddress
33 33 import functools
34 34 import traceback
35 35 import collections
36 36
37 37
38 38 from sqlalchemy import *
39 39 from sqlalchemy.ext.declarative import declared_attr
40 40 from sqlalchemy.ext.hybrid import hybrid_property
41 41 from sqlalchemy.orm import (
42 42 relationship, joinedload, class_mapper, validates, aliased)
43 43 from sqlalchemy.sql.expression import true
44 44 from beaker.cache import cache_region
45 45 from zope.cachedescriptors.property import Lazy as LazyProperty
46 46
47 47 from pyramid.threadlocal import get_current_request
48 48
49 49 from rhodecode.translation import _
50 50 from rhodecode.lib.vcs import get_vcs_instance
51 51 from rhodecode.lib.vcs.backends.base import EmptyCommit, Reference
52 52 from rhodecode.lib.utils2 import (
53 53 str2bool, safe_str, get_commit_safe, safe_unicode, md5_safe,
54 54 time_to_datetime, aslist, Optional, safe_int, get_clone_url, AttributeDict,
55 55 glob2re, StrictAttributeDict, cleaned_uri)
56 56 from rhodecode.lib.jsonalchemy import MutationObj, MutationList, JsonType
57 57 from rhodecode.lib.ext_json import json
58 58 from rhodecode.lib.caching_query import FromCache
59 59 from rhodecode.lib.encrypt import AESCipher
60 60
61 61 from rhodecode.model.meta import Base, Session
62 62
63 63 URL_SEP = '/'
64 64 log = logging.getLogger(__name__)
65 65
66 66 # =============================================================================
67 67 # BASE CLASSES
68 68 # =============================================================================
69 69
70 70 # this is propagated from .ini file rhodecode.encrypted_values.secret or
71 71 # beaker.session.secret if first is not set.
72 72 # and initialized at environment.py
73 73 ENCRYPTION_KEY = None
74 74
75 75 # used to sort permissions by types, '#' used here is not allowed to be in
76 76 # usernames, and it's very early in sorted string.printable table.
77 77 PERMISSION_TYPE_SORT = {
78 78 'admin': '####',
79 79 'write': '###',
80 80 'read': '##',
81 81 'none': '#',
82 82 }
83 83
84 84
85 85 def display_sort(obj):
86 86 """
87 87 Sort function used to sort permissions in .permissions() function of
88 88 Repository, RepoGroup, UserGroup. Also it put the default user in front
89 89 of all other resources
90 90 """
91 91
92 92 if obj.username == User.DEFAULT_USER:
93 93 return '#####'
94 94 prefix = PERMISSION_TYPE_SORT.get(obj.permission.split('.')[-1], '')
95 95 return prefix + obj.username
96 96
97 97
98 98 def _hash_key(k):
99 99 return md5_safe(k)
100 100
101 101
102 102 class EncryptedTextValue(TypeDecorator):
103 103 """
104 104 Special column for encrypted long text data, use like::
105 105
106 106 value = Column("encrypted_value", EncryptedValue(), nullable=False)
107 107
108 108 This column is intelligent so if value is in unencrypted form it return
109 109 unencrypted form, but on save it always encrypts
110 110 """
111 111 impl = Text
112 112
113 113 def process_bind_param(self, value, dialect):
114 114 if not value:
115 115 return value
116 116 if value.startswith('enc$aes$') or value.startswith('enc$aes_hmac$'):
117 117 # protect against double encrypting if someone manually starts
118 118 # doing
119 119 raise ValueError('value needs to be in unencrypted format, ie. '
120 120 'not starting with enc$aes')
121 121 return 'enc$aes_hmac$%s' % AESCipher(
122 122 ENCRYPTION_KEY, hmac=True).encrypt(value)
123 123
124 124 def process_result_value(self, value, dialect):
125 125 import rhodecode
126 126
127 127 if not value:
128 128 return value
129 129
130 130 parts = value.split('$', 3)
131 131 if not len(parts) == 3:
132 132 # probably not encrypted values
133 133 return value
134 134 else:
135 135 if parts[0] != 'enc':
136 136 # parts ok but without our header ?
137 137 return value
138 138 enc_strict_mode = str2bool(rhodecode.CONFIG.get(
139 139 'rhodecode.encrypted_values.strict') or True)
140 140 # at that stage we know it's our encryption
141 141 if parts[1] == 'aes':
142 142 decrypted_data = AESCipher(ENCRYPTION_KEY).decrypt(parts[2])
143 143 elif parts[1] == 'aes_hmac':
144 144 decrypted_data = AESCipher(
145 145 ENCRYPTION_KEY, hmac=True,
146 146 strict_verification=enc_strict_mode).decrypt(parts[2])
147 147 else:
148 148 raise ValueError(
149 149 'Encryption type part is wrong, must be `aes` '
150 150 'or `aes_hmac`, got `%s` instead' % (parts[1]))
151 151 return decrypted_data
152 152
153 153
154 154 class BaseModel(object):
155 155 """
156 156 Base Model for all classes
157 157 """
158 158
159 159 @classmethod
160 160 def _get_keys(cls):
161 161 """return column names for this model """
162 162 return class_mapper(cls).c.keys()
163 163
164 164 def get_dict(self):
165 165 """
166 166 return dict with keys and values corresponding
167 167 to this model data """
168 168
169 169 d = {}
170 170 for k in self._get_keys():
171 171 d[k] = getattr(self, k)
172 172
173 173 # also use __json__() if present to get additional fields
174 174 _json_attr = getattr(self, '__json__', None)
175 175 if _json_attr:
176 176 # update with attributes from __json__
177 177 if callable(_json_attr):
178 178 _json_attr = _json_attr()
179 179 for k, val in _json_attr.iteritems():
180 180 d[k] = val
181 181 return d
182 182
183 183 def get_appstruct(self):
184 184 """return list with keys and values tuples corresponding
185 185 to this model data """
186 186
187 187 l = []
188 188 for k in self._get_keys():
189 189 l.append((k, getattr(self, k),))
190 190 return l
191 191
192 192 def populate_obj(self, populate_dict):
193 193 """populate model with data from given populate_dict"""
194 194
195 195 for k in self._get_keys():
196 196 if k in populate_dict:
197 197 setattr(self, k, populate_dict[k])
198 198
199 199 @classmethod
200 200 def query(cls):
201 201 return Session().query(cls)
202 202
203 203 @classmethod
204 204 def get(cls, id_):
205 205 if id_:
206 206 return cls.query().get(id_)
207 207
208 208 @classmethod
209 209 def get_or_404(cls, id_):
210 210 from pyramid.httpexceptions import HTTPNotFound
211 211
212 212 try:
213 213 id_ = int(id_)
214 214 except (TypeError, ValueError):
215 215 raise HTTPNotFound()
216 216
217 217 res = cls.query().get(id_)
218 218 if not res:
219 219 raise HTTPNotFound()
220 220 return res
221 221
222 222 @classmethod
223 223 def getAll(cls):
224 224 # deprecated and left for backward compatibility
225 225 return cls.get_all()
226 226
227 227 @classmethod
228 228 def get_all(cls):
229 229 return cls.query().all()
230 230
231 231 @classmethod
232 232 def delete(cls, id_):
233 233 obj = cls.query().get(id_)
234 234 Session().delete(obj)
235 235
236 236 @classmethod
237 237 def identity_cache(cls, session, attr_name, value):
238 238 exist_in_session = []
239 239 for (item_cls, pkey), instance in session.identity_map.items():
240 240 if cls == item_cls and getattr(instance, attr_name) == value:
241 241 exist_in_session.append(instance)
242 242 if exist_in_session:
243 243 if len(exist_in_session) == 1:
244 244 return exist_in_session[0]
245 245 log.exception(
246 246 'multiple objects with attr %s and '
247 247 'value %s found with same name: %r',
248 248 attr_name, value, exist_in_session)
249 249
250 250 def __repr__(self):
251 251 if hasattr(self, '__unicode__'):
252 252 # python repr needs to return str
253 253 try:
254 254 return safe_str(self.__unicode__())
255 255 except UnicodeDecodeError:
256 256 pass
257 257 return '<DB:%s>' % (self.__class__.__name__)
258 258
259 259
260 260 class RhodeCodeSetting(Base, BaseModel):
261 261 __tablename__ = 'rhodecode_settings'
262 262 __table_args__ = (
263 263 UniqueConstraint('app_settings_name'),
264 264 {'extend_existing': True, 'mysql_engine': 'InnoDB',
265 265 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
266 266 )
267 267
268 268 SETTINGS_TYPES = {
269 269 'str': safe_str,
270 270 'int': safe_int,
271 271 'unicode': safe_unicode,
272 272 'bool': str2bool,
273 273 'list': functools.partial(aslist, sep=',')
274 274 }
275 275 DEFAULT_UPDATE_URL = 'https://rhodecode.com/api/v1/info/versions'
276 276 GLOBAL_CONF_KEY = 'app_settings'
277 277
278 278 app_settings_id = Column("app_settings_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
279 279 app_settings_name = Column("app_settings_name", String(255), nullable=True, unique=None, default=None)
280 280 _app_settings_value = Column("app_settings_value", String(4096), nullable=True, unique=None, default=None)
281 281 _app_settings_type = Column("app_settings_type", String(255), nullable=True, unique=None, default=None)
282 282
283 283 def __init__(self, key='', val='', type='unicode'):
284 284 self.app_settings_name = key
285 285 self.app_settings_type = type
286 286 self.app_settings_value = val
287 287
288 288 @validates('_app_settings_value')
289 289 def validate_settings_value(self, key, val):
290 290 assert type(val) == unicode
291 291 return val
292 292
293 293 @hybrid_property
294 294 def app_settings_value(self):
295 295 v = self._app_settings_value
296 296 _type = self.app_settings_type
297 297 if _type:
298 298 _type = self.app_settings_type.split('.')[0]
299 299 # decode the encrypted value
300 300 if 'encrypted' in self.app_settings_type:
301 301 cipher = EncryptedTextValue()
302 302 v = safe_unicode(cipher.process_result_value(v, None))
303 303
304 304 converter = self.SETTINGS_TYPES.get(_type) or \
305 305 self.SETTINGS_TYPES['unicode']
306 306 return converter(v)
307 307
308 308 @app_settings_value.setter
309 309 def app_settings_value(self, val):
310 310 """
311 311 Setter that will always make sure we use unicode in app_settings_value
312 312
313 313 :param val:
314 314 """
315 315 val = safe_unicode(val)
316 316 # encode the encrypted value
317 317 if 'encrypted' in self.app_settings_type:
318 318 cipher = EncryptedTextValue()
319 319 val = safe_unicode(cipher.process_bind_param(val, None))
320 320 self._app_settings_value = val
321 321
322 322 @hybrid_property
323 323 def app_settings_type(self):
324 324 return self._app_settings_type
325 325
326 326 @app_settings_type.setter
327 327 def app_settings_type(self, val):
328 328 if val.split('.')[0] not in self.SETTINGS_TYPES:
329 329 raise Exception('type must be one of %s got %s'
330 330 % (self.SETTINGS_TYPES.keys(), val))
331 331 self._app_settings_type = val
332 332
333 333 def __unicode__(self):
334 334 return u"<%s('%s:%s[%s]')>" % (
335 335 self.__class__.__name__,
336 336 self.app_settings_name, self.app_settings_value,
337 337 self.app_settings_type
338 338 )
339 339
340 340
341 341 class RhodeCodeUi(Base, BaseModel):
342 342 __tablename__ = 'rhodecode_ui'
343 343 __table_args__ = (
344 344 UniqueConstraint('ui_key'),
345 345 {'extend_existing': True, 'mysql_engine': 'InnoDB',
346 346 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
347 347 )
348 348
349 349 HOOK_REPO_SIZE = 'changegroup.repo_size'
350 350 # HG
351 351 HOOK_PRE_PULL = 'preoutgoing.pre_pull'
352 352 HOOK_PULL = 'outgoing.pull_logger'
353 353 HOOK_PRE_PUSH = 'prechangegroup.pre_push'
354 354 HOOK_PRETX_PUSH = 'pretxnchangegroup.pre_push'
355 355 HOOK_PUSH = 'changegroup.push_logger'
356 356 HOOK_PUSH_KEY = 'pushkey.key_push'
357 357
358 358 # TODO: johbo: Unify way how hooks are configured for git and hg,
359 359 # git part is currently hardcoded.
360 360
361 361 # SVN PATTERNS
362 362 SVN_BRANCH_ID = 'vcs_svn_branch'
363 363 SVN_TAG_ID = 'vcs_svn_tag'
364 364
365 365 ui_id = Column(
366 366 "ui_id", Integer(), nullable=False, unique=True, default=None,
367 367 primary_key=True)
368 368 ui_section = Column(
369 369 "ui_section", String(255), nullable=True, unique=None, default=None)
370 370 ui_key = Column(
371 371 "ui_key", String(255), nullable=True, unique=None, default=None)
372 372 ui_value = Column(
373 373 "ui_value", String(255), nullable=True, unique=None, default=None)
374 374 ui_active = Column(
375 375 "ui_active", Boolean(), nullable=True, unique=None, default=True)
376 376
377 377 def __repr__(self):
378 378 return '<%s[%s]%s=>%s]>' % (self.__class__.__name__, self.ui_section,
379 379 self.ui_key, self.ui_value)
380 380
381 381
382 382 class RepoRhodeCodeSetting(Base, BaseModel):
383 383 __tablename__ = 'repo_rhodecode_settings'
384 384 __table_args__ = (
385 385 UniqueConstraint(
386 386 'app_settings_name', 'repository_id',
387 387 name='uq_repo_rhodecode_setting_name_repo_id'),
388 388 {'extend_existing': True, 'mysql_engine': 'InnoDB',
389 389 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
390 390 )
391 391
392 392 repository_id = Column(
393 393 "repository_id", Integer(), ForeignKey('repositories.repo_id'),
394 394 nullable=False)
395 395 app_settings_id = Column(
396 396 "app_settings_id", Integer(), nullable=False, unique=True,
397 397 default=None, primary_key=True)
398 398 app_settings_name = Column(
399 399 "app_settings_name", String(255), nullable=True, unique=None,
400 400 default=None)
401 401 _app_settings_value = Column(
402 402 "app_settings_value", String(4096), nullable=True, unique=None,
403 403 default=None)
404 404 _app_settings_type = Column(
405 405 "app_settings_type", String(255), nullable=True, unique=None,
406 406 default=None)
407 407
408 408 repository = relationship('Repository')
409 409
410 410 def __init__(self, repository_id, key='', val='', type='unicode'):
411 411 self.repository_id = repository_id
412 412 self.app_settings_name = key
413 413 self.app_settings_type = type
414 414 self.app_settings_value = val
415 415
416 416 @validates('_app_settings_value')
417 417 def validate_settings_value(self, key, val):
418 418 assert type(val) == unicode
419 419 return val
420 420
421 421 @hybrid_property
422 422 def app_settings_value(self):
423 423 v = self._app_settings_value
424 424 type_ = self.app_settings_type
425 425 SETTINGS_TYPES = RhodeCodeSetting.SETTINGS_TYPES
426 426 converter = SETTINGS_TYPES.get(type_) or SETTINGS_TYPES['unicode']
427 427 return converter(v)
428 428
429 429 @app_settings_value.setter
430 430 def app_settings_value(self, val):
431 431 """
432 432 Setter that will always make sure we use unicode in app_settings_value
433 433
434 434 :param val:
435 435 """
436 436 self._app_settings_value = safe_unicode(val)
437 437
438 438 @hybrid_property
439 439 def app_settings_type(self):
440 440 return self._app_settings_type
441 441
442 442 @app_settings_type.setter
443 443 def app_settings_type(self, val):
444 444 SETTINGS_TYPES = RhodeCodeSetting.SETTINGS_TYPES
445 445 if val not in SETTINGS_TYPES:
446 446 raise Exception('type must be one of %s got %s'
447 447 % (SETTINGS_TYPES.keys(), val))
448 448 self._app_settings_type = val
449 449
450 450 def __unicode__(self):
451 451 return u"<%s('%s:%s:%s[%s]')>" % (
452 452 self.__class__.__name__, self.repository.repo_name,
453 453 self.app_settings_name, self.app_settings_value,
454 454 self.app_settings_type
455 455 )
456 456
457 457
458 458 class RepoRhodeCodeUi(Base, BaseModel):
459 459 __tablename__ = 'repo_rhodecode_ui'
460 460 __table_args__ = (
461 461 UniqueConstraint(
462 462 'repository_id', 'ui_section', 'ui_key',
463 463 name='uq_repo_rhodecode_ui_repository_id_section_key'),
464 464 {'extend_existing': True, 'mysql_engine': 'InnoDB',
465 465 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
466 466 )
467 467
468 468 repository_id = Column(
469 469 "repository_id", Integer(), ForeignKey('repositories.repo_id'),
470 470 nullable=False)
471 471 ui_id = Column(
472 472 "ui_id", Integer(), nullable=False, unique=True, default=None,
473 473 primary_key=True)
474 474 ui_section = Column(
475 475 "ui_section", String(255), nullable=True, unique=None, default=None)
476 476 ui_key = Column(
477 477 "ui_key", String(255), nullable=True, unique=None, default=None)
478 478 ui_value = Column(
479 479 "ui_value", String(255), nullable=True, unique=None, default=None)
480 480 ui_active = Column(
481 481 "ui_active", Boolean(), nullable=True, unique=None, default=True)
482 482
483 483 repository = relationship('Repository')
484 484
485 485 def __repr__(self):
486 486 return '<%s[%s:%s]%s=>%s]>' % (
487 487 self.__class__.__name__, self.repository.repo_name,
488 488 self.ui_section, self.ui_key, self.ui_value)
489 489
490 490
491 491 class User(Base, BaseModel):
492 492 __tablename__ = 'users'
493 493 __table_args__ = (
494 494 UniqueConstraint('username'), UniqueConstraint('email'),
495 495 Index('u_username_idx', 'username'),
496 496 Index('u_email_idx', 'email'),
497 497 {'extend_existing': True, 'mysql_engine': 'InnoDB',
498 498 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
499 499 )
500 500 DEFAULT_USER = 'default'
501 501 DEFAULT_USER_EMAIL = 'anonymous@rhodecode.org'
502 502 DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}'
503 503
504 504 user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
505 505 username = Column("username", String(255), nullable=True, unique=None, default=None)
506 506 password = Column("password", String(255), nullable=True, unique=None, default=None)
507 507 active = Column("active", Boolean(), nullable=True, unique=None, default=True)
508 508 admin = Column("admin", Boolean(), nullable=True, unique=None, default=False)
509 509 name = Column("firstname", String(255), nullable=True, unique=None, default=None)
510 510 lastname = Column("lastname", String(255), nullable=True, unique=None, default=None)
511 511 _email = Column("email", String(255), nullable=True, unique=None, default=None)
512 512 last_login = Column("last_login", DateTime(timezone=False), nullable=True, unique=None, default=None)
513 513 last_activity = Column('last_activity', DateTime(timezone=False), nullable=True, unique=None, default=None)
514 514
515 515 extern_type = Column("extern_type", String(255), nullable=True, unique=None, default=None)
516 516 extern_name = Column("extern_name", String(255), nullable=True, unique=None, default=None)
517 517 _api_key = Column("api_key", String(255), nullable=True, unique=None, default=None)
518 518 inherit_default_permissions = Column("inherit_default_permissions", Boolean(), nullable=False, unique=None, default=True)
519 519 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
520 520 _user_data = Column("user_data", LargeBinary(), nullable=True) # JSON data
521 521
522 522 user_log = relationship('UserLog')
523 523 user_perms = relationship('UserToPerm', primaryjoin="User.user_id==UserToPerm.user_id", cascade='all')
524 524
525 525 repositories = relationship('Repository')
526 526 repository_groups = relationship('RepoGroup')
527 527 user_groups = relationship('UserGroup')
528 528
529 529 user_followers = relationship('UserFollowing', primaryjoin='UserFollowing.follows_user_id==User.user_id', cascade='all')
530 530 followings = relationship('UserFollowing', primaryjoin='UserFollowing.user_id==User.user_id', cascade='all')
531 531
532 532 repo_to_perm = relationship('UserRepoToPerm', primaryjoin='UserRepoToPerm.user_id==User.user_id', cascade='all')
533 533 repo_group_to_perm = relationship('UserRepoGroupToPerm', primaryjoin='UserRepoGroupToPerm.user_id==User.user_id', cascade='all')
534 534 user_group_to_perm = relationship('UserUserGroupToPerm', primaryjoin='UserUserGroupToPerm.user_id==User.user_id', cascade='all')
535 535
536 536 group_member = relationship('UserGroupMember', cascade='all')
537 537
538 538 notifications = relationship('UserNotification', cascade='all')
539 539 # notifications assigned to this user
540 540 user_created_notifications = relationship('Notification', cascade='all')
541 541 # comments created by this user
542 542 user_comments = relationship('ChangesetComment', cascade='all')
543 543 # user profile extra info
544 544 user_emails = relationship('UserEmailMap', cascade='all')
545 545 user_ip_map = relationship('UserIpMap', cascade='all')
546 546 user_auth_tokens = relationship('UserApiKeys', cascade='all')
547 547 # gists
548 548 user_gists = relationship('Gist', cascade='all')
549 549 # user pull requests
550 550 user_pull_requests = relationship('PullRequest', cascade='all')
551 551 # external identities
552 552 extenal_identities = relationship(
553 553 'ExternalIdentity',
554 554 primaryjoin="User.user_id==ExternalIdentity.local_user_id",
555 555 cascade='all')
556 556
557 557 def __unicode__(self):
558 558 return u"<%s('id:%s:%s')>" % (self.__class__.__name__,
559 559 self.user_id, self.username)
560 560
561 561 @hybrid_property
562 562 def email(self):
563 563 return self._email
564 564
565 565 @email.setter
566 566 def email(self, val):
567 567 self._email = val.lower() if val else None
568 568
569 569 @hybrid_property
570 570 def first_name(self):
571 571 from rhodecode.lib import helpers as h
572 572 if self.name:
573 573 return h.escape(self.name)
574 574 return self.name
575 575
576 576 @hybrid_property
577 577 def last_name(self):
578 578 from rhodecode.lib import helpers as h
579 579 if self.lastname:
580 580 return h.escape(self.lastname)
581 581 return self.lastname
582 582
583 583 @hybrid_property
584 584 def api_key(self):
585 585 """
586 586 Fetch if exist an auth-token with role ALL connected to this user
587 587 """
588 588 user_auth_token = UserApiKeys.query()\
589 589 .filter(UserApiKeys.user_id == self.user_id)\
590 590 .filter(or_(UserApiKeys.expires == -1,
591 591 UserApiKeys.expires >= time.time()))\
592 592 .filter(UserApiKeys.role == UserApiKeys.ROLE_ALL).first()
593 593 if user_auth_token:
594 594 user_auth_token = user_auth_token.api_key
595 595
596 596 return user_auth_token
597 597
598 598 @api_key.setter
599 599 def api_key(self, val):
600 600 # don't allow to set API key this is deprecated for now
601 601 self._api_key = None
602 602
603 603 @property
604 604 def reviewer_pull_requests(self):
605 605 return PullRequestReviewers.query() \
606 606 .options(joinedload(PullRequestReviewers.pull_request)) \
607 607 .filter(PullRequestReviewers.user_id == self.user_id) \
608 608 .all()
609 609
610 610 @property
611 611 def firstname(self):
612 612 # alias for future
613 613 return self.name
614 614
615 615 @property
616 616 def emails(self):
617 617 other = UserEmailMap.query().filter(UserEmailMap.user == self).all()
618 618 return [self.email] + [x.email for x in other]
619 619
620 620 @property
621 621 def auth_tokens(self):
622 622 auth_tokens = self.get_auth_tokens()
623 623 return [x.api_key for x in auth_tokens]
624 624
625 625 def get_auth_tokens(self):
626 626 return UserApiKeys.query().filter(UserApiKeys.user == self).all()
627 627
628 628 @property
629 629 def feed_token(self):
630 630 return self.get_feed_token()
631 631
632 632 def get_feed_token(self):
633 633 feed_tokens = UserApiKeys.query()\
634 634 .filter(UserApiKeys.user == self)\
635 635 .filter(UserApiKeys.role == UserApiKeys.ROLE_FEED)\
636 636 .all()
637 637 if feed_tokens:
638 638 return feed_tokens[0].api_key
639 639 return 'NO_FEED_TOKEN_AVAILABLE'
640 640
641 641 @classmethod
642 642 def extra_valid_auth_tokens(cls, user, role=None):
643 643 tokens = UserApiKeys.query().filter(UserApiKeys.user == user)\
644 644 .filter(or_(UserApiKeys.expires == -1,
645 645 UserApiKeys.expires >= time.time()))
646 646 if role:
647 647 tokens = tokens.filter(or_(UserApiKeys.role == role,
648 648 UserApiKeys.role == UserApiKeys.ROLE_ALL))
649 649 return tokens.all()
650 650
651 651 def authenticate_by_token(self, auth_token, roles=None, scope_repo_id=None):
652 652 from rhodecode.lib import auth
653 653
654 654 log.debug('Trying to authenticate user: %s via auth-token, '
655 655 'and roles: %s', self, roles)
656 656
657 657 if not auth_token:
658 658 return False
659 659
660 660 crypto_backend = auth.crypto_backend()
661 661
662 662 roles = (roles or []) + [UserApiKeys.ROLE_ALL]
663 663 tokens_q = UserApiKeys.query()\
664 664 .filter(UserApiKeys.user_id == self.user_id)\
665 665 .filter(or_(UserApiKeys.expires == -1,
666 666 UserApiKeys.expires >= time.time()))
667 667
668 668 tokens_q = tokens_q.filter(UserApiKeys.role.in_(roles))
669 669
670 670 plain_tokens = []
671 671 hash_tokens = []
672 672
673 673 for token in tokens_q.all():
674 674 # verify scope first
675 675 if token.repo_id:
676 676 # token has a scope, we need to verify it
677 677 if scope_repo_id != token.repo_id:
678 678 log.debug(
679 679 'Scope mismatch: token has a set repo scope: %s, '
680 680 'and calling scope is:%s, skipping further checks',
681 681 token.repo, scope_repo_id)
682 682 # token has a scope, and it doesn't match, skip token
683 683 continue
684 684
685 685 if token.api_key.startswith(crypto_backend.ENC_PREF):
686 686 hash_tokens.append(token.api_key)
687 687 else:
688 688 plain_tokens.append(token.api_key)
689 689
690 690 is_plain_match = auth_token in plain_tokens
691 691 if is_plain_match:
692 692 return True
693 693
694 694 for hashed in hash_tokens:
695 695 # TODO(marcink): this is expensive to calculate, but most secure
696 696 match = crypto_backend.hash_check(auth_token, hashed)
697 697 if match:
698 698 return True
699 699
700 700 return False
701 701
702 702 @property
703 703 def ip_addresses(self):
704 704 ret = UserIpMap.query().filter(UserIpMap.user == self).all()
705 705 return [x.ip_addr for x in ret]
706 706
707 707 @property
708 708 def username_and_name(self):
709 709 return '%s (%s %s)' % (self.username, self.first_name, self.last_name)
710 710
711 711 @property
712 712 def username_or_name_or_email(self):
713 713 full_name = self.full_name if self.full_name is not ' ' else None
714 714 return self.username or full_name or self.email
715 715
716 716 @property
717 717 def full_name(self):
718 718 return '%s %s' % (self.first_name, self.last_name)
719 719
720 720 @property
721 721 def full_name_or_username(self):
722 722 return ('%s %s' % (self.first_name, self.last_name)
723 723 if (self.first_name and self.last_name) else self.username)
724 724
725 725 @property
726 726 def full_contact(self):
727 727 return '%s %s <%s>' % (self.first_name, self.last_name, self.email)
728 728
729 729 @property
730 730 def short_contact(self):
731 731 return '%s %s' % (self.first_name, self.last_name)
732 732
733 733 @property
734 734 def is_admin(self):
735 735 return self.admin
736 736
737 737 @property
738 738 def AuthUser(self):
739 739 """
740 740 Returns instance of AuthUser for this user
741 741 """
742 742 from rhodecode.lib.auth import AuthUser
743 743 return AuthUser(user_id=self.user_id, username=self.username)
744 744
745 745 @hybrid_property
746 746 def user_data(self):
747 747 if not self._user_data:
748 748 return {}
749 749
750 750 try:
751 751 return json.loads(self._user_data)
752 752 except TypeError:
753 753 return {}
754 754
755 755 @user_data.setter
756 756 def user_data(self, val):
757 757 if not isinstance(val, dict):
758 758 raise Exception('user_data must be dict, got %s' % type(val))
759 759 try:
760 760 self._user_data = json.dumps(val)
761 761 except Exception:
762 762 log.error(traceback.format_exc())
763 763
764 764 @classmethod
765 765 def get_by_username(cls, username, case_insensitive=False,
766 766 cache=False, identity_cache=False):
767 767 session = Session()
768 768
769 769 if case_insensitive:
770 770 q = cls.query().filter(
771 771 func.lower(cls.username) == func.lower(username))
772 772 else:
773 773 q = cls.query().filter(cls.username == username)
774 774
775 775 if cache:
776 776 if identity_cache:
777 777 val = cls.identity_cache(session, 'username', username)
778 778 if val:
779 779 return val
780 780 else:
781 781 cache_key = "get_user_by_name_%s" % _hash_key(username)
782 782 q = q.options(
783 783 FromCache("sql_cache_short", cache_key))
784 784
785 785 return q.scalar()
786 786
787 787 @classmethod
788 788 def get_by_auth_token(cls, auth_token, cache=False):
789 789 q = UserApiKeys.query()\
790 790 .filter(UserApiKeys.api_key == auth_token)\
791 791 .filter(or_(UserApiKeys.expires == -1,
792 792 UserApiKeys.expires >= time.time()))
793 793 if cache:
794 794 q = q.options(
795 795 FromCache("sql_cache_short", "get_auth_token_%s" % auth_token))
796 796
797 797 match = q.first()
798 798 if match:
799 799 return match.user
800 800
801 801 @classmethod
802 802 def get_by_email(cls, email, case_insensitive=False, cache=False):
803 803
804 804 if case_insensitive:
805 805 q = cls.query().filter(func.lower(cls.email) == func.lower(email))
806 806
807 807 else:
808 808 q = cls.query().filter(cls.email == email)
809 809
810 810 email_key = _hash_key(email)
811 811 if cache:
812 812 q = q.options(
813 813 FromCache("sql_cache_short", "get_email_key_%s" % email_key))
814 814
815 815 ret = q.scalar()
816 816 if ret is None:
817 817 q = UserEmailMap.query()
818 818 # try fetching in alternate email map
819 819 if case_insensitive:
820 820 q = q.filter(func.lower(UserEmailMap.email) == func.lower(email))
821 821 else:
822 822 q = q.filter(UserEmailMap.email == email)
823 823 q = q.options(joinedload(UserEmailMap.user))
824 824 if cache:
825 825 q = q.options(
826 826 FromCache("sql_cache_short", "get_email_map_key_%s" % email_key))
827 827 ret = getattr(q.scalar(), 'user', None)
828 828
829 829 return ret
830 830
831 831 @classmethod
832 832 def get_from_cs_author(cls, author):
833 833 """
834 834 Tries to get User objects out of commit author string
835 835
836 836 :param author:
837 837 """
838 838 from rhodecode.lib.helpers import email, author_name
839 839 # Valid email in the attribute passed, see if they're in the system
840 840 _email = email(author)
841 841 if _email:
842 842 user = cls.get_by_email(_email, case_insensitive=True)
843 843 if user:
844 844 return user
845 845 # Maybe we can match by username?
846 846 _author = author_name(author)
847 847 user = cls.get_by_username(_author, case_insensitive=True)
848 848 if user:
849 849 return user
850 850
851 851 def update_userdata(self, **kwargs):
852 852 usr = self
853 853 old = usr.user_data
854 854 old.update(**kwargs)
855 855 usr.user_data = old
856 856 Session().add(usr)
857 857 log.debug('updated userdata with ', kwargs)
858 858
859 859 def update_lastlogin(self):
860 860 """Update user lastlogin"""
861 861 self.last_login = datetime.datetime.now()
862 862 Session().add(self)
863 863 log.debug('updated user %s lastlogin', self.username)
864 864
865 865 def update_lastactivity(self):
866 866 """Update user lastactivity"""
867 867 self.last_activity = datetime.datetime.now()
868 868 Session().add(self)
869 869 log.debug('updated user %s lastactivity', self.username)
870 870
871 871 def update_password(self, new_password):
872 872 from rhodecode.lib.auth import get_crypt_password
873 873
874 874 self.password = get_crypt_password(new_password)
875 875 Session().add(self)
876 876
877 877 @classmethod
878 878 def get_first_super_admin(cls):
879 879 user = User.query().filter(User.admin == true()).first()
880 880 if user is None:
881 881 raise Exception('FATAL: Missing administrative account!')
882 882 return user
883 883
884 884 @classmethod
885 885 def get_all_super_admins(cls):
886 886 """
887 887 Returns all admin accounts sorted by username
888 888 """
889 889 return User.query().filter(User.admin == true())\
890 890 .order_by(User.username.asc()).all()
891 891
892 892 @classmethod
893 893 def get_default_user(cls, cache=False, refresh=False):
894 894 user = User.get_by_username(User.DEFAULT_USER, cache=cache)
895 895 if user is None:
896 896 raise Exception('FATAL: Missing default account!')
897 897 if refresh:
898 898 # The default user might be based on outdated state which
899 899 # has been loaded from the cache.
900 900 # A call to refresh() ensures that the
901 901 # latest state from the database is used.
902 902 Session().refresh(user)
903 903 return user
904 904
905 905 def _get_default_perms(self, user, suffix=''):
906 906 from rhodecode.model.permission import PermissionModel
907 907 return PermissionModel().get_default_perms(user.user_perms, suffix)
908 908
909 909 def get_default_perms(self, suffix=''):
910 910 return self._get_default_perms(self, suffix)
911 911
912 912 def get_api_data(self, include_secrets=False, details='full'):
913 913 """
914 914 Common function for generating user related data for API
915 915
916 916 :param include_secrets: By default secrets in the API data will be replaced
917 917 by a placeholder value to prevent exposing this data by accident. In case
918 918 this data shall be exposed, set this flag to ``True``.
919 919
920 920 :param details: details can be 'basic|full' basic gives only a subset of
921 921 the available user information that includes user_id, name and emails.
922 922 """
923 923 user = self
924 924 user_data = self.user_data
925 925 data = {
926 926 'user_id': user.user_id,
927 927 'username': user.username,
928 928 'firstname': user.name,
929 929 'lastname': user.lastname,
930 930 'email': user.email,
931 931 'emails': user.emails,
932 932 }
933 933 if details == 'basic':
934 934 return data
935 935
936 936 auth_token_length = 40
937 937 auth_token_replacement = '*' * auth_token_length
938 938
939 939 extras = {
940 940 'auth_tokens': [auth_token_replacement],
941 941 'active': user.active,
942 942 'admin': user.admin,
943 943 'extern_type': user.extern_type,
944 944 'extern_name': user.extern_name,
945 945 'last_login': user.last_login,
946 946 'last_activity': user.last_activity,
947 947 'ip_addresses': user.ip_addresses,
948 948 'language': user_data.get('language')
949 949 }
950 950 data.update(extras)
951 951
952 952 if include_secrets:
953 953 data['auth_tokens'] = user.auth_tokens
954 954 return data
955 955
956 956 def __json__(self):
957 957 data = {
958 958 'full_name': self.full_name,
959 959 'full_name_or_username': self.full_name_or_username,
960 960 'short_contact': self.short_contact,
961 961 'full_contact': self.full_contact,
962 962 }
963 963 data.update(self.get_api_data())
964 964 return data
965 965
966 966
967 967 class UserApiKeys(Base, BaseModel):
968 968 __tablename__ = 'user_api_keys'
969 969 __table_args__ = (
970 970 Index('uak_api_key_idx', 'api_key', unique=True),
971 971 Index('uak_api_key_expires_idx', 'api_key', 'expires'),
972 972 {'extend_existing': True, 'mysql_engine': 'InnoDB',
973 973 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
974 974 )
975 975 __mapper_args__ = {}
976 976
977 977 # ApiKey role
978 978 ROLE_ALL = 'token_role_all'
979 979 ROLE_HTTP = 'token_role_http'
980 980 ROLE_VCS = 'token_role_vcs'
981 981 ROLE_API = 'token_role_api'
982 982 ROLE_FEED = 'token_role_feed'
983 983 ROLE_PASSWORD_RESET = 'token_password_reset'
984 984
985 985 ROLES = [ROLE_ALL, ROLE_HTTP, ROLE_VCS, ROLE_API, ROLE_FEED]
986 986
987 987 user_api_key_id = Column("user_api_key_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
988 988 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
989 989 api_key = Column("api_key", String(255), nullable=False, unique=True)
990 990 description = Column('description', UnicodeText().with_variant(UnicodeText(1024), 'mysql'))
991 991 expires = Column('expires', Float(53), nullable=False)
992 992 role = Column('role', String(255), nullable=True)
993 993 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
994 994
995 995 # scope columns
996 996 repo_id = Column(
997 997 'repo_id', Integer(), ForeignKey('repositories.repo_id'),
998 998 nullable=True, unique=None, default=None)
999 999 repo = relationship('Repository', lazy='joined')
1000 1000
1001 1001 repo_group_id = Column(
1002 1002 'repo_group_id', Integer(), ForeignKey('groups.group_id'),
1003 1003 nullable=True, unique=None, default=None)
1004 1004 repo_group = relationship('RepoGroup', lazy='joined')
1005 1005
1006 1006 user = relationship('User', lazy='joined')
1007 1007
1008 1008 def __unicode__(self):
1009 1009 return u"<%s('%s')>" % (self.__class__.__name__, self.role)
1010 1010
1011 1011 def __json__(self):
1012 1012 data = {
1013 1013 'auth_token': self.api_key,
1014 1014 'role': self.role,
1015 1015 'scope': self.scope_humanized,
1016 1016 'expired': self.expired
1017 1017 }
1018 1018 return data
1019 1019
1020 1020 def get_api_data(self, include_secrets=False):
1021 1021 data = self.__json__()
1022 1022 if include_secrets:
1023 1023 return data
1024 1024 else:
1025 1025 data['auth_token'] = self.token_obfuscated
1026 1026 return data
1027 1027
1028 1028 @hybrid_property
1029 1029 def description_safe(self):
1030 1030 from rhodecode.lib import helpers as h
1031 1031 return h.escape(self.description)
1032 1032
1033 1033 @property
1034 1034 def expired(self):
1035 1035 if self.expires == -1:
1036 1036 return False
1037 1037 return time.time() > self.expires
1038 1038
1039 1039 @classmethod
1040 1040 def _get_role_name(cls, role):
1041 1041 return {
1042 1042 cls.ROLE_ALL: _('all'),
1043 1043 cls.ROLE_HTTP: _('http/web interface'),
1044 1044 cls.ROLE_VCS: _('vcs (git/hg/svn protocol)'),
1045 1045 cls.ROLE_API: _('api calls'),
1046 1046 cls.ROLE_FEED: _('feed access'),
1047 1047 }.get(role, role)
1048 1048
1049 1049 @property
1050 1050 def role_humanized(self):
1051 1051 return self._get_role_name(self.role)
1052 1052
1053 1053 def _get_scope(self):
1054 1054 if self.repo:
1055 1055 return repr(self.repo)
1056 1056 if self.repo_group:
1057 1057 return repr(self.repo_group) + ' (recursive)'
1058 1058 return 'global'
1059 1059
1060 1060 @property
1061 1061 def scope_humanized(self):
1062 1062 return self._get_scope()
1063 1063
1064 1064 @property
1065 1065 def token_obfuscated(self):
1066 1066 if self.api_key:
1067 1067 return self.api_key[:4] + "****"
1068 1068
1069 1069
1070 1070 class UserEmailMap(Base, BaseModel):
1071 1071 __tablename__ = 'user_email_map'
1072 1072 __table_args__ = (
1073 1073 Index('uem_email_idx', 'email'),
1074 1074 UniqueConstraint('email'),
1075 1075 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1076 1076 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
1077 1077 )
1078 1078 __mapper_args__ = {}
1079 1079
1080 1080 email_id = Column("email_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1081 1081 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
1082 1082 _email = Column("email", String(255), nullable=True, unique=False, default=None)
1083 1083 user = relationship('User', lazy='joined')
1084 1084
1085 1085 @validates('_email')
1086 1086 def validate_email(self, key, email):
1087 1087 # check if this email is not main one
1088 1088 main_email = Session().query(User).filter(User.email == email).scalar()
1089 1089 if main_email is not None:
1090 1090 raise AttributeError('email %s is present is user table' % email)
1091 1091 return email
1092 1092
1093 1093 @hybrid_property
1094 1094 def email(self):
1095 1095 return self._email
1096 1096
1097 1097 @email.setter
1098 1098 def email(self, val):
1099 1099 self._email = val.lower() if val else None
1100 1100
1101 1101
1102 1102 class UserIpMap(Base, BaseModel):
1103 1103 __tablename__ = 'user_ip_map'
1104 1104 __table_args__ = (
1105 1105 UniqueConstraint('user_id', 'ip_addr'),
1106 1106 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1107 1107 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
1108 1108 )
1109 1109 __mapper_args__ = {}
1110 1110
1111 1111 ip_id = Column("ip_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1112 1112 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
1113 1113 ip_addr = Column("ip_addr", String(255), nullable=True, unique=False, default=None)
1114 1114 active = Column("active", Boolean(), nullable=True, unique=None, default=True)
1115 1115 description = Column("description", String(10000), nullable=True, unique=None, default=None)
1116 1116 user = relationship('User', lazy='joined')
1117 1117
1118 1118 @hybrid_property
1119 1119 def description_safe(self):
1120 1120 from rhodecode.lib import helpers as h
1121 1121 return h.escape(self.description)
1122 1122
1123 1123 @classmethod
1124 1124 def _get_ip_range(cls, ip_addr):
1125 1125 net = ipaddress.ip_network(safe_unicode(ip_addr), strict=False)
1126 1126 return [str(net.network_address), str(net.broadcast_address)]
1127 1127
1128 1128 def __json__(self):
1129 1129 return {
1130 1130 'ip_addr': self.ip_addr,
1131 1131 'ip_range': self._get_ip_range(self.ip_addr),
1132 1132 }
1133 1133
1134 1134 def __unicode__(self):
1135 1135 return u"<%s('user_id:%s=>%s')>" % (self.__class__.__name__,
1136 1136 self.user_id, self.ip_addr)
1137 1137
1138 1138
1139 1139 class UserLog(Base, BaseModel):
1140 1140 __tablename__ = 'user_logs'
1141 1141 __table_args__ = (
1142 1142 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1143 1143 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1144 1144 )
1145 1145 VERSION_1 = 'v1'
1146 1146 VERSION_2 = 'v2'
1147 1147 VERSIONS = [VERSION_1, VERSION_2]
1148 1148
1149 1149 user_log_id = Column("user_log_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1150 1150 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
1151 1151 username = Column("username", String(255), nullable=True, unique=None, default=None)
1152 1152 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=True)
1153 1153 repository_name = Column("repository_name", String(255), nullable=True, unique=None, default=None)
1154 1154 user_ip = Column("user_ip", String(255), nullable=True, unique=None, default=None)
1155 1155 action = Column("action", Text().with_variant(Text(1200000), 'mysql'), nullable=True, unique=None, default=None)
1156 1156 action_date = Column("action_date", DateTime(timezone=False), nullable=True, unique=None, default=None)
1157 1157
1158 1158 version = Column("version", String(255), nullable=True, default=VERSION_1)
1159 1159 user_data = Column('user_data_json', MutationObj.as_mutable(JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
1160 1160 action_data = Column('action_data_json', MutationObj.as_mutable(JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
1161 1161
1162 1162 def __unicode__(self):
1163 1163 return u"<%s('id:%s:%s')>" % (
1164 1164 self.__class__.__name__, self.repository_name, self.action)
1165 1165
1166 1166 def __json__(self):
1167 1167 return {
1168 1168 'user_id': self.user_id,
1169 1169 'username': self.username,
1170 1170 'repository_id': self.repository_id,
1171 1171 'repository_name': self.repository_name,
1172 1172 'user_ip': self.user_ip,
1173 1173 'action_date': self.action_date,
1174 1174 'action': self.action,
1175 1175 }
1176 1176
1177 1177 @property
1178 1178 def action_as_day(self):
1179 1179 return datetime.date(*self.action_date.timetuple()[:3])
1180 1180
1181 1181 user = relationship('User')
1182 1182 repository = relationship('Repository', cascade='')
1183 1183
1184 1184
1185 1185 class UserGroup(Base, BaseModel):
1186 1186 __tablename__ = 'users_groups'
1187 1187 __table_args__ = (
1188 1188 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1189 1189 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1190 1190 )
1191 1191
1192 1192 users_group_id = Column("users_group_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1193 1193 users_group_name = Column("users_group_name", String(255), nullable=False, unique=True, default=None)
1194 1194 user_group_description = Column("user_group_description", String(10000), nullable=True, unique=None, default=None)
1195 1195 users_group_active = Column("users_group_active", Boolean(), nullable=True, unique=None, default=None)
1196 1196 inherit_default_permissions = Column("users_group_inherit_default_permissions", Boolean(), nullable=False, unique=None, default=True)
1197 1197 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=False, default=None)
1198 1198 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
1199 1199 _group_data = Column("group_data", LargeBinary(), nullable=True) # JSON data
1200 1200
1201 1201 members = relationship('UserGroupMember', cascade="all, delete, delete-orphan", lazy="joined")
1202 1202 users_group_to_perm = relationship('UserGroupToPerm', cascade='all')
1203 1203 users_group_repo_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
1204 1204 users_group_repo_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
1205 1205 user_user_group_to_perm = relationship('UserUserGroupToPerm', cascade='all')
1206 1206 user_group_user_group_to_perm = relationship('UserGroupUserGroupToPerm ', primaryjoin="UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id", cascade='all')
1207 1207
1208 1208 user = relationship('User')
1209 1209
1210 1210 @hybrid_property
1211 1211 def description_safe(self):
1212 1212 from rhodecode.lib import helpers as h
1213 1213 return h.escape(self.description)
1214 1214
1215 1215 @hybrid_property
1216 1216 def group_data(self):
1217 1217 if not self._group_data:
1218 1218 return {}
1219 1219
1220 1220 try:
1221 1221 return json.loads(self._group_data)
1222 1222 except TypeError:
1223 1223 return {}
1224 1224
1225 1225 @group_data.setter
1226 1226 def group_data(self, val):
1227 1227 try:
1228 1228 self._group_data = json.dumps(val)
1229 1229 except Exception:
1230 1230 log.error(traceback.format_exc())
1231 1231
1232 1232 def __unicode__(self):
1233 1233 return u"<%s('id:%s:%s')>" % (self.__class__.__name__,
1234 1234 self.users_group_id,
1235 1235 self.users_group_name)
1236 1236
1237 1237 @classmethod
1238 1238 def get_by_group_name(cls, group_name, cache=False,
1239 1239 case_insensitive=False):
1240 1240 if case_insensitive:
1241 1241 q = cls.query().filter(func.lower(cls.users_group_name) ==
1242 1242 func.lower(group_name))
1243 1243
1244 1244 else:
1245 1245 q = cls.query().filter(cls.users_group_name == group_name)
1246 1246 if cache:
1247 1247 q = q.options(
1248 1248 FromCache("sql_cache_short", "get_group_%s" % _hash_key(group_name)))
1249 1249 return q.scalar()
1250 1250
1251 1251 @classmethod
1252 1252 def get(cls, user_group_id, cache=False):
1253 1253 user_group = cls.query()
1254 1254 if cache:
1255 1255 user_group = user_group.options(
1256 1256 FromCache("sql_cache_short", "get_users_group_%s" % user_group_id))
1257 1257 return user_group.get(user_group_id)
1258 1258
1259 1259 def permissions(self, with_admins=True, with_owner=True):
1260 1260 q = UserUserGroupToPerm.query().filter(UserUserGroupToPerm.user_group == self)
1261 1261 q = q.options(joinedload(UserUserGroupToPerm.user_group),
1262 1262 joinedload(UserUserGroupToPerm.user),
1263 1263 joinedload(UserUserGroupToPerm.permission),)
1264 1264
1265 1265 # get owners and admins and permissions. We do a trick of re-writing
1266 1266 # objects from sqlalchemy to named-tuples due to sqlalchemy session
1267 1267 # has a global reference and changing one object propagates to all
1268 1268 # others. This means if admin is also an owner admin_row that change
1269 1269 # would propagate to both objects
1270 1270 perm_rows = []
1271 1271 for _usr in q.all():
1272 1272 usr = AttributeDict(_usr.user.get_dict())
1273 1273 usr.permission = _usr.permission.permission_name
1274 1274 perm_rows.append(usr)
1275 1275
1276 1276 # filter the perm rows by 'default' first and then sort them by
1277 1277 # admin,write,read,none permissions sorted again alphabetically in
1278 1278 # each group
1279 1279 perm_rows = sorted(perm_rows, key=display_sort)
1280 1280
1281 1281 _admin_perm = 'usergroup.admin'
1282 1282 owner_row = []
1283 1283 if with_owner:
1284 1284 usr = AttributeDict(self.user.get_dict())
1285 1285 usr.owner_row = True
1286 1286 usr.permission = _admin_perm
1287 1287 owner_row.append(usr)
1288 1288
1289 1289 super_admin_rows = []
1290 1290 if with_admins:
1291 1291 for usr in User.get_all_super_admins():
1292 1292 # if this admin is also owner, don't double the record
1293 1293 if usr.user_id == owner_row[0].user_id:
1294 1294 owner_row[0].admin_row = True
1295 1295 else:
1296 1296 usr = AttributeDict(usr.get_dict())
1297 1297 usr.admin_row = True
1298 1298 usr.permission = _admin_perm
1299 1299 super_admin_rows.append(usr)
1300 1300
1301 1301 return super_admin_rows + owner_row + perm_rows
1302 1302
1303 1303 def permission_user_groups(self):
1304 1304 q = UserGroupUserGroupToPerm.query().filter(UserGroupUserGroupToPerm.target_user_group == self)
1305 1305 q = q.options(joinedload(UserGroupUserGroupToPerm.user_group),
1306 1306 joinedload(UserGroupUserGroupToPerm.target_user_group),
1307 1307 joinedload(UserGroupUserGroupToPerm.permission),)
1308 1308
1309 1309 perm_rows = []
1310 1310 for _user_group in q.all():
1311 1311 usr = AttributeDict(_user_group.user_group.get_dict())
1312 1312 usr.permission = _user_group.permission.permission_name
1313 1313 perm_rows.append(usr)
1314 1314
1315 1315 return perm_rows
1316 1316
1317 1317 def _get_default_perms(self, user_group, suffix=''):
1318 1318 from rhodecode.model.permission import PermissionModel
1319 1319 return PermissionModel().get_default_perms(user_group.users_group_to_perm, suffix)
1320 1320
1321 1321 def get_default_perms(self, suffix=''):
1322 1322 return self._get_default_perms(self, suffix)
1323 1323
1324 1324 def get_api_data(self, with_group_members=True, include_secrets=False):
1325 1325 """
1326 1326 :param include_secrets: See :meth:`User.get_api_data`, this parameter is
1327 1327 basically forwarded.
1328 1328
1329 1329 """
1330 1330 user_group = self
1331 1331 data = {
1332 1332 'users_group_id': user_group.users_group_id,
1333 1333 'group_name': user_group.users_group_name,
1334 1334 'group_description': user_group.user_group_description,
1335 1335 'active': user_group.users_group_active,
1336 1336 'owner': user_group.user.username,
1337 1337 'owner_email': user_group.user.email,
1338 1338 }
1339 1339
1340 1340 if with_group_members:
1341 1341 users = []
1342 1342 for user in user_group.members:
1343 1343 user = user.user
1344 1344 users.append(user.get_api_data(include_secrets=include_secrets))
1345 1345 data['users'] = users
1346 1346
1347 1347 return data
1348 1348
1349 1349
1350 1350 class UserGroupMember(Base, BaseModel):
1351 1351 __tablename__ = 'users_groups_members'
1352 1352 __table_args__ = (
1353 1353 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1354 1354 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1355 1355 )
1356 1356
1357 1357 users_group_member_id = Column("users_group_member_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1358 1358 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
1359 1359 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
1360 1360
1361 1361 user = relationship('User', lazy='joined')
1362 1362 users_group = relationship('UserGroup')
1363 1363
1364 1364 def __init__(self, gr_id='', u_id=''):
1365 1365 self.users_group_id = gr_id
1366 1366 self.user_id = u_id
1367 1367
1368 1368
1369 1369 class RepositoryField(Base, BaseModel):
1370 1370 __tablename__ = 'repositories_fields'
1371 1371 __table_args__ = (
1372 1372 UniqueConstraint('repository_id', 'field_key'), # no-multi field
1373 1373 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1374 1374 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1375 1375 )
1376 1376 PREFIX = 'ex_' # prefix used in form to not conflict with already existing fields
1377 1377
1378 1378 repo_field_id = Column("repo_field_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1379 1379 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=None, default=None)
1380 1380 field_key = Column("field_key", String(250))
1381 1381 field_label = Column("field_label", String(1024), nullable=False)
1382 1382 field_value = Column("field_value", String(10000), nullable=False)
1383 1383 field_desc = Column("field_desc", String(1024), nullable=False)
1384 1384 field_type = Column("field_type", String(255), nullable=False, unique=None)
1385 1385 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
1386 1386
1387 1387 repository = relationship('Repository')
1388 1388
1389 1389 @property
1390 1390 def field_key_prefixed(self):
1391 1391 return 'ex_%s' % self.field_key
1392 1392
1393 1393 @classmethod
1394 1394 def un_prefix_key(cls, key):
1395 1395 if key.startswith(cls.PREFIX):
1396 1396 return key[len(cls.PREFIX):]
1397 1397 return key
1398 1398
1399 1399 @classmethod
1400 1400 def get_by_key_name(cls, key, repo):
1401 1401 row = cls.query()\
1402 1402 .filter(cls.repository == repo)\
1403 1403 .filter(cls.field_key == key).scalar()
1404 1404 return row
1405 1405
1406 1406
1407 1407 class Repository(Base, BaseModel):
1408 1408 __tablename__ = 'repositories'
1409 1409 __table_args__ = (
1410 1410 Index('r_repo_name_idx', 'repo_name', mysql_length=255),
1411 1411 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1412 1412 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1413 1413 )
1414 1414 DEFAULT_CLONE_URI = '{scheme}://{user}@{netloc}/{repo}'
1415 1415 DEFAULT_CLONE_URI_ID = '{scheme}://{user}@{netloc}/_{repoid}'
1416 1416
1417 1417 STATE_CREATED = 'repo_state_created'
1418 1418 STATE_PENDING = 'repo_state_pending'
1419 1419 STATE_ERROR = 'repo_state_error'
1420 1420
1421 1421 LOCK_AUTOMATIC = 'lock_auto'
1422 1422 LOCK_API = 'lock_api'
1423 1423 LOCK_WEB = 'lock_web'
1424 1424 LOCK_PULL = 'lock_pull'
1425 1425
1426 1426 NAME_SEP = URL_SEP
1427 1427
1428 1428 repo_id = Column(
1429 1429 "repo_id", Integer(), nullable=False, unique=True, default=None,
1430 1430 primary_key=True)
1431 1431 _repo_name = Column(
1432 1432 "repo_name", Text(), nullable=False, default=None)
1433 1433 _repo_name_hash = Column(
1434 1434 "repo_name_hash", String(255), nullable=False, unique=True)
1435 1435 repo_state = Column("repo_state", String(255), nullable=True)
1436 1436
1437 1437 clone_uri = Column(
1438 1438 "clone_uri", EncryptedTextValue(), nullable=True, unique=False,
1439 1439 default=None)
1440 1440 repo_type = Column(
1441 1441 "repo_type", String(255), nullable=False, unique=False, default=None)
1442 1442 user_id = Column(
1443 1443 "user_id", Integer(), ForeignKey('users.user_id'), nullable=False,
1444 1444 unique=False, default=None)
1445 1445 private = Column(
1446 1446 "private", Boolean(), nullable=True, unique=None, default=None)
1447 1447 enable_statistics = Column(
1448 1448 "statistics", Boolean(), nullable=True, unique=None, default=True)
1449 1449 enable_downloads = Column(
1450 1450 "downloads", Boolean(), nullable=True, unique=None, default=True)
1451 1451 description = Column(
1452 1452 "description", String(10000), nullable=True, unique=None, default=None)
1453 1453 created_on = Column(
1454 1454 'created_on', DateTime(timezone=False), nullable=True, unique=None,
1455 1455 default=datetime.datetime.now)
1456 1456 updated_on = Column(
1457 1457 'updated_on', DateTime(timezone=False), nullable=True, unique=None,
1458 1458 default=datetime.datetime.now)
1459 1459 _landing_revision = Column(
1460 1460 "landing_revision", String(255), nullable=False, unique=False,
1461 1461 default=None)
1462 1462 enable_locking = Column(
1463 1463 "enable_locking", Boolean(), nullable=False, unique=None,
1464 1464 default=False)
1465 1465 _locked = Column(
1466 1466 "locked", String(255), nullable=True, unique=False, default=None)
1467 1467 _changeset_cache = Column(
1468 1468 "changeset_cache", LargeBinary(), nullable=True) # JSON data
1469 1469
1470 1470 fork_id = Column(
1471 1471 "fork_id", Integer(), ForeignKey('repositories.repo_id'),
1472 1472 nullable=True, unique=False, default=None)
1473 1473 group_id = Column(
1474 1474 "group_id", Integer(), ForeignKey('groups.group_id'), nullable=True,
1475 1475 unique=False, default=None)
1476 1476
1477 1477 user = relationship('User', lazy='joined')
1478 1478 fork = relationship('Repository', remote_side=repo_id, lazy='joined')
1479 1479 group = relationship('RepoGroup', lazy='joined')
1480 1480 repo_to_perm = relationship(
1481 1481 'UserRepoToPerm', cascade='all',
1482 1482 order_by='UserRepoToPerm.repo_to_perm_id')
1483 1483 users_group_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
1484 1484 stats = relationship('Statistics', cascade='all', uselist=False)
1485 1485
1486 1486 followers = relationship(
1487 1487 'UserFollowing',
1488 1488 primaryjoin='UserFollowing.follows_repo_id==Repository.repo_id',
1489 1489 cascade='all')
1490 1490 extra_fields = relationship(
1491 1491 'RepositoryField', cascade="all, delete, delete-orphan")
1492 1492 logs = relationship('UserLog')
1493 1493 comments = relationship(
1494 1494 'ChangesetComment', cascade="all, delete, delete-orphan")
1495 1495 pull_requests_source = relationship(
1496 1496 'PullRequest',
1497 1497 primaryjoin='PullRequest.source_repo_id==Repository.repo_id',
1498 1498 cascade="all, delete, delete-orphan")
1499 1499 pull_requests_target = relationship(
1500 1500 'PullRequest',
1501 1501 primaryjoin='PullRequest.target_repo_id==Repository.repo_id',
1502 1502 cascade="all, delete, delete-orphan")
1503 1503 ui = relationship('RepoRhodeCodeUi', cascade="all")
1504 1504 settings = relationship('RepoRhodeCodeSetting', cascade="all")
1505 1505 integrations = relationship('Integration',
1506 1506 cascade="all, delete, delete-orphan")
1507 1507
1508 1508 def __unicode__(self):
1509 1509 return u"<%s('%s:%s')>" % (self.__class__.__name__, self.repo_id,
1510 1510 safe_unicode(self.repo_name))
1511 1511
1512 1512 @hybrid_property
1513 1513 def description_safe(self):
1514 1514 from rhodecode.lib import helpers as h
1515 1515 return h.escape(self.description)
1516 1516
1517 1517 @hybrid_property
1518 1518 def landing_rev(self):
1519 1519 # always should return [rev_type, rev]
1520 1520 if self._landing_revision:
1521 1521 _rev_info = self._landing_revision.split(':')
1522 1522 if len(_rev_info) < 2:
1523 1523 _rev_info.insert(0, 'rev')
1524 1524 return [_rev_info[0], _rev_info[1]]
1525 1525 return [None, None]
1526 1526
1527 1527 @landing_rev.setter
1528 1528 def landing_rev(self, val):
1529 1529 if ':' not in val:
1530 1530 raise ValueError('value must be delimited with `:` and consist '
1531 1531 'of <rev_type>:<rev>, got %s instead' % val)
1532 1532 self._landing_revision = val
1533 1533
1534 1534 @hybrid_property
1535 1535 def locked(self):
1536 1536 if self._locked:
1537 1537 user_id, timelocked, reason = self._locked.split(':')
1538 1538 lock_values = int(user_id), timelocked, reason
1539 1539 else:
1540 1540 lock_values = [None, None, None]
1541 1541 return lock_values
1542 1542
1543 1543 @locked.setter
1544 1544 def locked(self, val):
1545 1545 if val and isinstance(val, (list, tuple)):
1546 1546 self._locked = ':'.join(map(str, val))
1547 1547 else:
1548 1548 self._locked = None
1549 1549
1550 1550 @hybrid_property
1551 1551 def changeset_cache(self):
1552 1552 from rhodecode.lib.vcs.backends.base import EmptyCommit
1553 1553 dummy = EmptyCommit().__json__()
1554 1554 if not self._changeset_cache:
1555 1555 return dummy
1556 1556 try:
1557 1557 return json.loads(self._changeset_cache)
1558 1558 except TypeError:
1559 1559 return dummy
1560 1560 except Exception:
1561 1561 log.error(traceback.format_exc())
1562 1562 return dummy
1563 1563
1564 1564 @changeset_cache.setter
1565 1565 def changeset_cache(self, val):
1566 1566 try:
1567 1567 self._changeset_cache = json.dumps(val)
1568 1568 except Exception:
1569 1569 log.error(traceback.format_exc())
1570 1570
1571 1571 @hybrid_property
1572 1572 def repo_name(self):
1573 1573 return self._repo_name
1574 1574
1575 1575 @repo_name.setter
1576 1576 def repo_name(self, value):
1577 1577 self._repo_name = value
1578 1578 self._repo_name_hash = hashlib.sha1(safe_str(value)).hexdigest()
1579 1579
1580 1580 @classmethod
1581 1581 def normalize_repo_name(cls, repo_name):
1582 1582 """
1583 1583 Normalizes os specific repo_name to the format internally stored inside
1584 1584 database using URL_SEP
1585 1585
1586 1586 :param cls:
1587 1587 :param repo_name:
1588 1588 """
1589 1589 return cls.NAME_SEP.join(repo_name.split(os.sep))
1590 1590
1591 1591 @classmethod
1592 1592 def get_by_repo_name(cls, repo_name, cache=False, identity_cache=False):
1593 1593 session = Session()
1594 1594 q = session.query(cls).filter(cls.repo_name == repo_name)
1595 1595
1596 1596 if cache:
1597 1597 if identity_cache:
1598 1598 val = cls.identity_cache(session, 'repo_name', repo_name)
1599 1599 if val:
1600 1600 return val
1601 1601 else:
1602 1602 cache_key = "get_repo_by_name_%s" % _hash_key(repo_name)
1603 1603 q = q.options(
1604 1604 FromCache("sql_cache_short", cache_key))
1605 1605
1606 1606 return q.scalar()
1607 1607
1608 1608 @classmethod
1609 1609 def get_by_full_path(cls, repo_full_path):
1610 1610 repo_name = repo_full_path.split(cls.base_path(), 1)[-1]
1611 1611 repo_name = cls.normalize_repo_name(repo_name)
1612 1612 return cls.get_by_repo_name(repo_name.strip(URL_SEP))
1613 1613
1614 1614 @classmethod
1615 1615 def get_repo_forks(cls, repo_id):
1616 1616 return cls.query().filter(Repository.fork_id == repo_id)
1617 1617
1618 1618 @classmethod
1619 1619 def base_path(cls):
1620 1620 """
1621 1621 Returns base path when all repos are stored
1622 1622
1623 1623 :param cls:
1624 1624 """
1625 1625 q = Session().query(RhodeCodeUi)\
1626 1626 .filter(RhodeCodeUi.ui_key == cls.NAME_SEP)
1627 1627 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
1628 1628 return q.one().ui_value
1629 1629
1630 1630 @classmethod
1631 1631 def is_valid(cls, repo_name):
1632 1632 """
1633 1633 returns True if given repo name is a valid filesystem repository
1634 1634
1635 1635 :param cls:
1636 1636 :param repo_name:
1637 1637 """
1638 1638 from rhodecode.lib.utils import is_valid_repo
1639 1639
1640 1640 return is_valid_repo(repo_name, cls.base_path())
1641 1641
1642 1642 @classmethod
1643 1643 def get_all_repos(cls, user_id=Optional(None), group_id=Optional(None),
1644 1644 case_insensitive=True):
1645 1645 q = Repository.query()
1646 1646
1647 1647 if not isinstance(user_id, Optional):
1648 1648 q = q.filter(Repository.user_id == user_id)
1649 1649
1650 1650 if not isinstance(group_id, Optional):
1651 1651 q = q.filter(Repository.group_id == group_id)
1652 1652
1653 1653 if case_insensitive:
1654 1654 q = q.order_by(func.lower(Repository.repo_name))
1655 1655 else:
1656 1656 q = q.order_by(Repository.repo_name)
1657 1657 return q.all()
1658 1658
1659 1659 @property
1660 1660 def forks(self):
1661 1661 """
1662 1662 Return forks of this repo
1663 1663 """
1664 1664 return Repository.get_repo_forks(self.repo_id)
1665 1665
1666 1666 @property
1667 1667 def parent(self):
1668 1668 """
1669 1669 Returns fork parent
1670 1670 """
1671 1671 return self.fork
1672 1672
1673 1673 @property
1674 1674 def just_name(self):
1675 1675 return self.repo_name.split(self.NAME_SEP)[-1]
1676 1676
1677 1677 @property
1678 1678 def groups_with_parents(self):
1679 1679 groups = []
1680 1680 if self.group is None:
1681 1681 return groups
1682 1682
1683 1683 cur_gr = self.group
1684 1684 groups.insert(0, cur_gr)
1685 1685 while 1:
1686 1686 gr = getattr(cur_gr, 'parent_group', None)
1687 1687 cur_gr = cur_gr.parent_group
1688 1688 if gr is None:
1689 1689 break
1690 1690 groups.insert(0, gr)
1691 1691
1692 1692 return groups
1693 1693
1694 1694 @property
1695 1695 def groups_and_repo(self):
1696 1696 return self.groups_with_parents, self
1697 1697
1698 1698 @LazyProperty
1699 1699 def repo_path(self):
1700 1700 """
1701 1701 Returns base full path for that repository means where it actually
1702 1702 exists on a filesystem
1703 1703 """
1704 1704 q = Session().query(RhodeCodeUi).filter(
1705 1705 RhodeCodeUi.ui_key == self.NAME_SEP)
1706 1706 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
1707 1707 return q.one().ui_value
1708 1708
1709 1709 @property
1710 1710 def repo_full_path(self):
1711 1711 p = [self.repo_path]
1712 1712 # we need to split the name by / since this is how we store the
1713 1713 # names in the database, but that eventually needs to be converted
1714 1714 # into a valid system path
1715 1715 p += self.repo_name.split(self.NAME_SEP)
1716 1716 return os.path.join(*map(safe_unicode, p))
1717 1717
1718 1718 @property
1719 1719 def cache_keys(self):
1720 1720 """
1721 1721 Returns associated cache keys for that repo
1722 1722 """
1723 1723 return CacheKey.query()\
1724 1724 .filter(CacheKey.cache_args == self.repo_name)\
1725 1725 .order_by(CacheKey.cache_key)\
1726 1726 .all()
1727 1727
1728 1728 def get_new_name(self, repo_name):
1729 1729 """
1730 1730 returns new full repository name based on assigned group and new new
1731 1731
1732 1732 :param group_name:
1733 1733 """
1734 1734 path_prefix = self.group.full_path_splitted if self.group else []
1735 1735 return self.NAME_SEP.join(path_prefix + [repo_name])
1736 1736
1737 1737 @property
1738 1738 def _config(self):
1739 1739 """
1740 1740 Returns db based config object.
1741 1741 """
1742 1742 from rhodecode.lib.utils import make_db_config
1743 1743 return make_db_config(clear_session=False, repo=self)
1744 1744
1745 1745 def permissions(self, with_admins=True, with_owner=True):
1746 1746 q = UserRepoToPerm.query().filter(UserRepoToPerm.repository == self)
1747 1747 q = q.options(joinedload(UserRepoToPerm.repository),
1748 1748 joinedload(UserRepoToPerm.user),
1749 1749 joinedload(UserRepoToPerm.permission),)
1750 1750
1751 1751 # get owners and admins and permissions. We do a trick of re-writing
1752 1752 # objects from sqlalchemy to named-tuples due to sqlalchemy session
1753 1753 # has a global reference and changing one object propagates to all
1754 1754 # others. This means if admin is also an owner admin_row that change
1755 1755 # would propagate to both objects
1756 1756 perm_rows = []
1757 1757 for _usr in q.all():
1758 1758 usr = AttributeDict(_usr.user.get_dict())
1759 1759 usr.permission = _usr.permission.permission_name
1760 1760 perm_rows.append(usr)
1761 1761
1762 1762 # filter the perm rows by 'default' first and then sort them by
1763 1763 # admin,write,read,none permissions sorted again alphabetically in
1764 1764 # each group
1765 1765 perm_rows = sorted(perm_rows, key=display_sort)
1766 1766
1767 1767 _admin_perm = 'repository.admin'
1768 1768 owner_row = []
1769 1769 if with_owner:
1770 1770 usr = AttributeDict(self.user.get_dict())
1771 1771 usr.owner_row = True
1772 1772 usr.permission = _admin_perm
1773 1773 owner_row.append(usr)
1774 1774
1775 1775 super_admin_rows = []
1776 1776 if with_admins:
1777 1777 for usr in User.get_all_super_admins():
1778 1778 # if this admin is also owner, don't double the record
1779 1779 if usr.user_id == owner_row[0].user_id:
1780 1780 owner_row[0].admin_row = True
1781 1781 else:
1782 1782 usr = AttributeDict(usr.get_dict())
1783 1783 usr.admin_row = True
1784 1784 usr.permission = _admin_perm
1785 1785 super_admin_rows.append(usr)
1786 1786
1787 1787 return super_admin_rows + owner_row + perm_rows
1788 1788
1789 1789 def permission_user_groups(self):
1790 1790 q = UserGroupRepoToPerm.query().filter(
1791 1791 UserGroupRepoToPerm.repository == self)
1792 1792 q = q.options(joinedload(UserGroupRepoToPerm.repository),
1793 1793 joinedload(UserGroupRepoToPerm.users_group),
1794 1794 joinedload(UserGroupRepoToPerm.permission),)
1795 1795
1796 1796 perm_rows = []
1797 1797 for _user_group in q.all():
1798 1798 usr = AttributeDict(_user_group.users_group.get_dict())
1799 1799 usr.permission = _user_group.permission.permission_name
1800 1800 perm_rows.append(usr)
1801 1801
1802 1802 return perm_rows
1803 1803
1804 1804 def get_api_data(self, include_secrets=False):
1805 1805 """
1806 1806 Common function for generating repo api data
1807 1807
1808 1808 :param include_secrets: See :meth:`User.get_api_data`.
1809 1809
1810 1810 """
1811 1811 # TODO: mikhail: Here there is an anti-pattern, we probably need to
1812 1812 # move this methods on models level.
1813 1813 from rhodecode.model.settings import SettingsModel
1814 1814 from rhodecode.model.repo import RepoModel
1815 1815
1816 1816 repo = self
1817 1817 _user_id, _time, _reason = self.locked
1818 1818
1819 1819 data = {
1820 1820 'repo_id': repo.repo_id,
1821 1821 'repo_name': repo.repo_name,
1822 1822 'repo_type': repo.repo_type,
1823 1823 'clone_uri': repo.clone_uri or '',
1824 1824 'url': RepoModel().get_url(self),
1825 1825 'private': repo.private,
1826 1826 'created_on': repo.created_on,
1827 1827 'description': repo.description_safe,
1828 1828 'landing_rev': repo.landing_rev,
1829 1829 'owner': repo.user.username,
1830 1830 'fork_of': repo.fork.repo_name if repo.fork else None,
1831 1831 'fork_of_id': repo.fork.repo_id if repo.fork else None,
1832 1832 'enable_statistics': repo.enable_statistics,
1833 1833 'enable_locking': repo.enable_locking,
1834 1834 'enable_downloads': repo.enable_downloads,
1835 1835 'last_changeset': repo.changeset_cache,
1836 1836 'locked_by': User.get(_user_id).get_api_data(
1837 1837 include_secrets=include_secrets) if _user_id else None,
1838 1838 'locked_date': time_to_datetime(_time) if _time else None,
1839 1839 'lock_reason': _reason if _reason else None,
1840 1840 }
1841 1841
1842 1842 # TODO: mikhail: should be per-repo settings here
1843 1843 rc_config = SettingsModel().get_all_settings()
1844 1844 repository_fields = str2bool(
1845 1845 rc_config.get('rhodecode_repository_fields'))
1846 1846 if repository_fields:
1847 1847 for f in self.extra_fields:
1848 1848 data[f.field_key_prefixed] = f.field_value
1849 1849
1850 1850 return data
1851 1851
1852 1852 @classmethod
1853 1853 def lock(cls, repo, user_id, lock_time=None, lock_reason=None):
1854 1854 if not lock_time:
1855 1855 lock_time = time.time()
1856 1856 if not lock_reason:
1857 1857 lock_reason = cls.LOCK_AUTOMATIC
1858 1858 repo.locked = [user_id, lock_time, lock_reason]
1859 1859 Session().add(repo)
1860 1860 Session().commit()
1861 1861
1862 1862 @classmethod
1863 1863 def unlock(cls, repo):
1864 1864 repo.locked = None
1865 1865 Session().add(repo)
1866 1866 Session().commit()
1867 1867
1868 1868 @classmethod
1869 1869 def getlock(cls, repo):
1870 1870 return repo.locked
1871 1871
1872 1872 def is_user_lock(self, user_id):
1873 1873 if self.lock[0]:
1874 1874 lock_user_id = safe_int(self.lock[0])
1875 1875 user_id = safe_int(user_id)
1876 1876 # both are ints, and they are equal
1877 1877 return all([lock_user_id, user_id]) and lock_user_id == user_id
1878 1878
1879 1879 return False
1880 1880
1881 1881 def get_locking_state(self, action, user_id, only_when_enabled=True):
1882 1882 """
1883 1883 Checks locking on this repository, if locking is enabled and lock is
1884 1884 present returns a tuple of make_lock, locked, locked_by.
1885 1885 make_lock can have 3 states None (do nothing) True, make lock
1886 1886 False release lock, This value is later propagated to hooks, which
1887 1887 do the locking. Think about this as signals passed to hooks what to do.
1888 1888
1889 1889 """
1890 1890 # TODO: johbo: This is part of the business logic and should be moved
1891 1891 # into the RepositoryModel.
1892 1892
1893 1893 if action not in ('push', 'pull'):
1894 1894 raise ValueError("Invalid action value: %s" % repr(action))
1895 1895
1896 1896 # defines if locked error should be thrown to user
1897 1897 currently_locked = False
1898 1898 # defines if new lock should be made, tri-state
1899 1899 make_lock = None
1900 1900 repo = self
1901 1901 user = User.get(user_id)
1902 1902
1903 1903 lock_info = repo.locked
1904 1904
1905 1905 if repo and (repo.enable_locking or not only_when_enabled):
1906 1906 if action == 'push':
1907 1907 # check if it's already locked !, if it is compare users
1908 1908 locked_by_user_id = lock_info[0]
1909 1909 if user.user_id == locked_by_user_id:
1910 1910 log.debug(
1911 1911 'Got `push` action from user %s, now unlocking', user)
1912 1912 # unlock if we have push from user who locked
1913 1913 make_lock = False
1914 1914 else:
1915 1915 # we're not the same user who locked, ban with
1916 1916 # code defined in settings (default is 423 HTTP Locked) !
1917 1917 log.debug('Repo %s is currently locked by %s', repo, user)
1918 1918 currently_locked = True
1919 1919 elif action == 'pull':
1920 1920 # [0] user [1] date
1921 1921 if lock_info[0] and lock_info[1]:
1922 1922 log.debug('Repo %s is currently locked by %s', repo, user)
1923 1923 currently_locked = True
1924 1924 else:
1925 1925 log.debug('Setting lock on repo %s by %s', repo, user)
1926 1926 make_lock = True
1927 1927
1928 1928 else:
1929 1929 log.debug('Repository %s do not have locking enabled', repo)
1930 1930
1931 1931 log.debug('FINAL locking values make_lock:%s,locked:%s,locked_by:%s',
1932 1932 make_lock, currently_locked, lock_info)
1933 1933
1934 1934 from rhodecode.lib.auth import HasRepoPermissionAny
1935 1935 perm_check = HasRepoPermissionAny('repository.write', 'repository.admin')
1936 1936 if make_lock and not perm_check(repo_name=repo.repo_name, user=user):
1937 1937 # if we don't have at least write permission we cannot make a lock
1938 1938 log.debug('lock state reset back to FALSE due to lack '
1939 1939 'of at least read permission')
1940 1940 make_lock = False
1941 1941
1942 1942 return make_lock, currently_locked, lock_info
1943 1943
1944 1944 @property
1945 1945 def last_db_change(self):
1946 1946 return self.updated_on
1947 1947
1948 1948 @property
1949 1949 def clone_uri_hidden(self):
1950 1950 clone_uri = self.clone_uri
1951 1951 if clone_uri:
1952 1952 import urlobject
1953 1953 url_obj = urlobject.URLObject(cleaned_uri(clone_uri))
1954 1954 if url_obj.password:
1955 1955 clone_uri = url_obj.with_password('*****')
1956 1956 return clone_uri
1957 1957
1958 1958 def clone_url(self, **override):
1959 1959 from rhodecode.model.settings import SettingsModel
1960 1960
1961 1961 uri_tmpl = None
1962 1962 if 'with_id' in override:
1963 1963 uri_tmpl = self.DEFAULT_CLONE_URI_ID
1964 1964 del override['with_id']
1965 1965
1966 1966 if 'uri_tmpl' in override:
1967 1967 uri_tmpl = override['uri_tmpl']
1968 1968 del override['uri_tmpl']
1969 1969
1970 1970 # we didn't override our tmpl from **overrides
1971 1971 if not uri_tmpl:
1972 1972 rc_config = SettingsModel().get_all_settings(cache=True)
1973 1973 uri_tmpl = rc_config.get(
1974 1974 'rhodecode_clone_uri_tmpl') or self.DEFAULT_CLONE_URI
1975 1975
1976 1976 request = get_current_request()
1977 1977 return get_clone_url(request=request,
1978 1978 uri_tmpl=uri_tmpl,
1979 1979 repo_name=self.repo_name,
1980 1980 repo_id=self.repo_id, **override)
1981 1981
1982 1982 def set_state(self, state):
1983 1983 self.repo_state = state
1984 1984 Session().add(self)
1985 1985 #==========================================================================
1986 1986 # SCM PROPERTIES
1987 1987 #==========================================================================
1988 1988
1989 1989 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
1990 1990 return get_commit_safe(
1991 1991 self.scm_instance(), commit_id, commit_idx, pre_load=pre_load)
1992 1992
1993 1993 def get_changeset(self, rev=None, pre_load=None):
1994 1994 warnings.warn("Use get_commit", DeprecationWarning)
1995 1995 commit_id = None
1996 1996 commit_idx = None
1997 1997 if isinstance(rev, basestring):
1998 1998 commit_id = rev
1999 1999 else:
2000 2000 commit_idx = rev
2001 2001 return self.get_commit(commit_id=commit_id, commit_idx=commit_idx,
2002 2002 pre_load=pre_load)
2003 2003
2004 2004 def get_landing_commit(self):
2005 2005 """
2006 2006 Returns landing commit, or if that doesn't exist returns the tip
2007 2007 """
2008 2008 _rev_type, _rev = self.landing_rev
2009 2009 commit = self.get_commit(_rev)
2010 2010 if isinstance(commit, EmptyCommit):
2011 2011 return self.get_commit()
2012 2012 return commit
2013 2013
2014 2014 def update_commit_cache(self, cs_cache=None, config=None):
2015 2015 """
2016 2016 Update cache of last changeset for repository, keys should be::
2017 2017
2018 2018 short_id
2019 2019 raw_id
2020 2020 revision
2021 2021 parents
2022 2022 message
2023 2023 date
2024 2024 author
2025 2025
2026 2026 :param cs_cache:
2027 2027 """
2028 2028 from rhodecode.lib.vcs.backends.base import BaseChangeset
2029 2029 if cs_cache is None:
2030 2030 # use no-cache version here
2031 2031 scm_repo = self.scm_instance(cache=False, config=config)
2032 2032 if scm_repo:
2033 2033 cs_cache = scm_repo.get_commit(
2034 2034 pre_load=["author", "date", "message", "parents"])
2035 2035 else:
2036 2036 cs_cache = EmptyCommit()
2037 2037
2038 2038 if isinstance(cs_cache, BaseChangeset):
2039 2039 cs_cache = cs_cache.__json__()
2040 2040
2041 2041 def is_outdated(new_cs_cache):
2042 2042 if (new_cs_cache['raw_id'] != self.changeset_cache['raw_id'] or
2043 2043 new_cs_cache['revision'] != self.changeset_cache['revision']):
2044 2044 return True
2045 2045 return False
2046 2046
2047 2047 # check if we have maybe already latest cached revision
2048 2048 if is_outdated(cs_cache) or not self.changeset_cache:
2049 2049 _default = datetime.datetime.fromtimestamp(0)
2050 2050 last_change = cs_cache.get('date') or _default
2051 2051 log.debug('updated repo %s with new cs cache %s',
2052 2052 self.repo_name, cs_cache)
2053 2053 self.updated_on = last_change
2054 2054 self.changeset_cache = cs_cache
2055 2055 Session().add(self)
2056 2056 Session().commit()
2057 2057 else:
2058 2058 log.debug('Skipping update_commit_cache for repo:`%s` '
2059 2059 'commit already with latest changes', self.repo_name)
2060 2060
2061 2061 @property
2062 2062 def tip(self):
2063 2063 return self.get_commit('tip')
2064 2064
2065 2065 @property
2066 2066 def author(self):
2067 2067 return self.tip.author
2068 2068
2069 2069 @property
2070 2070 def last_change(self):
2071 2071 return self.scm_instance().last_change
2072 2072
2073 2073 def get_comments(self, revisions=None):
2074 2074 """
2075 2075 Returns comments for this repository grouped by revisions
2076 2076
2077 2077 :param revisions: filter query by revisions only
2078 2078 """
2079 2079 cmts = ChangesetComment.query()\
2080 2080 .filter(ChangesetComment.repo == self)
2081 2081 if revisions:
2082 2082 cmts = cmts.filter(ChangesetComment.revision.in_(revisions))
2083 2083 grouped = collections.defaultdict(list)
2084 2084 for cmt in cmts.all():
2085 2085 grouped[cmt.revision].append(cmt)
2086 2086 return grouped
2087 2087
2088 2088 def statuses(self, revisions=None):
2089 2089 """
2090 2090 Returns statuses for this repository
2091 2091
2092 2092 :param revisions: list of revisions to get statuses for
2093 2093 """
2094 2094 statuses = ChangesetStatus.query()\
2095 2095 .filter(ChangesetStatus.repo == self)\
2096 2096 .filter(ChangesetStatus.version == 0)
2097 2097
2098 2098 if revisions:
2099 2099 # Try doing the filtering in chunks to avoid hitting limits
2100 2100 size = 500
2101 2101 status_results = []
2102 2102 for chunk in xrange(0, len(revisions), size):
2103 2103 status_results += statuses.filter(
2104 2104 ChangesetStatus.revision.in_(
2105 2105 revisions[chunk: chunk+size])
2106 2106 ).all()
2107 2107 else:
2108 2108 status_results = statuses.all()
2109 2109
2110 2110 grouped = {}
2111 2111
2112 2112 # maybe we have open new pullrequest without a status?
2113 2113 stat = ChangesetStatus.STATUS_UNDER_REVIEW
2114 2114 status_lbl = ChangesetStatus.get_status_lbl(stat)
2115 2115 for pr in PullRequest.query().filter(PullRequest.source_repo == self).all():
2116 2116 for rev in pr.revisions:
2117 2117 pr_id = pr.pull_request_id
2118 2118 pr_repo = pr.target_repo.repo_name
2119 2119 grouped[rev] = [stat, status_lbl, pr_id, pr_repo]
2120 2120
2121 2121 for stat in status_results:
2122 2122 pr_id = pr_repo = None
2123 2123 if stat.pull_request:
2124 2124 pr_id = stat.pull_request.pull_request_id
2125 2125 pr_repo = stat.pull_request.target_repo.repo_name
2126 2126 grouped[stat.revision] = [str(stat.status), stat.status_lbl,
2127 2127 pr_id, pr_repo]
2128 2128 return grouped
2129 2129
2130 2130 # ==========================================================================
2131 2131 # SCM CACHE INSTANCE
2132 2132 # ==========================================================================
2133 2133
2134 2134 def scm_instance(self, **kwargs):
2135 2135 import rhodecode
2136 2136
2137 2137 # Passing a config will not hit the cache currently only used
2138 2138 # for repo2dbmapper
2139 2139 config = kwargs.pop('config', None)
2140 2140 cache = kwargs.pop('cache', None)
2141 2141 full_cache = str2bool(rhodecode.CONFIG.get('vcs_full_cache'))
2142 2142 # if cache is NOT defined use default global, else we have a full
2143 2143 # control over cache behaviour
2144 2144 if cache is None and full_cache and not config:
2145 2145 return self._get_instance_cached()
2146 2146 return self._get_instance(cache=bool(cache), config=config)
2147 2147
2148 2148 def _get_instance_cached(self):
2149 2149 @cache_region('long_term')
2150 2150 def _get_repo(cache_key):
2151 2151 return self._get_instance()
2152 2152
2153 2153 invalidator_context = CacheKey.repo_context_cache(
2154 2154 _get_repo, self.repo_name, None, thread_scoped=True)
2155 2155
2156 2156 with invalidator_context as context:
2157 2157 context.invalidate()
2158 2158 repo = context.compute()
2159 2159
2160 2160 return repo
2161 2161
2162 2162 def _get_instance(self, cache=True, config=None):
2163 2163 config = config or self._config
2164 2164 custom_wire = {
2165 2165 'cache': cache # controls the vcs.remote cache
2166 2166 }
2167 2167 repo = get_vcs_instance(
2168 2168 repo_path=safe_str(self.repo_full_path),
2169 2169 config=config,
2170 2170 with_wire=custom_wire,
2171 2171 create=False,
2172 2172 _vcs_alias=self.repo_type)
2173 2173
2174 2174 return repo
2175 2175
2176 2176 def __json__(self):
2177 2177 return {'landing_rev': self.landing_rev}
2178 2178
2179 2179 def get_dict(self):
2180 2180
2181 2181 # Since we transformed `repo_name` to a hybrid property, we need to
2182 2182 # keep compatibility with the code which uses `repo_name` field.
2183 2183
2184 2184 result = super(Repository, self).get_dict()
2185 2185 result['repo_name'] = result.pop('_repo_name', None)
2186 2186 return result
2187 2187
2188 2188
2189 2189 class RepoGroup(Base, BaseModel):
2190 2190 __tablename__ = 'groups'
2191 2191 __table_args__ = (
2192 2192 UniqueConstraint('group_name', 'group_parent_id'),
2193 2193 CheckConstraint('group_id != group_parent_id'),
2194 2194 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2195 2195 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
2196 2196 )
2197 2197 __mapper_args__ = {'order_by': 'group_name'}
2198 2198
2199 2199 CHOICES_SEPARATOR = '/' # used to generate select2 choices for nested groups
2200 2200
2201 2201 group_id = Column("group_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2202 2202 group_name = Column("group_name", String(255), nullable=False, unique=True, default=None)
2203 2203 group_parent_id = Column("group_parent_id", Integer(), ForeignKey('groups.group_id'), nullable=True, unique=None, default=None)
2204 2204 group_description = Column("group_description", String(10000), nullable=True, unique=None, default=None)
2205 2205 enable_locking = Column("enable_locking", Boolean(), nullable=False, unique=None, default=False)
2206 2206 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=False, default=None)
2207 2207 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2208 2208 updated_on = Column('updated_on', DateTime(timezone=False), nullable=True, unique=None, default=datetime.datetime.now)
2209 2209 personal = Column('personal', Boolean(), nullable=True, unique=None, default=None)
2210 2210
2211 2211 repo_group_to_perm = relationship('UserRepoGroupToPerm', cascade='all', order_by='UserRepoGroupToPerm.group_to_perm_id')
2212 2212 users_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
2213 2213 parent_group = relationship('RepoGroup', remote_side=group_id)
2214 2214 user = relationship('User')
2215 2215 integrations = relationship('Integration',
2216 2216 cascade="all, delete, delete-orphan")
2217 2217
2218 2218 def __init__(self, group_name='', parent_group=None):
2219 2219 self.group_name = group_name
2220 2220 self.parent_group = parent_group
2221 2221
2222 2222 def __unicode__(self):
2223 2223 return u"<%s('id:%s:%s')>" % (
2224 2224 self.__class__.__name__, self.group_id, self.group_name)
2225 2225
2226 2226 @hybrid_property
2227 2227 def description_safe(self):
2228 2228 from rhodecode.lib import helpers as h
2229 2229 return h.escape(self.group_description)
2230 2230
2231 2231 @classmethod
2232 2232 def _generate_choice(cls, repo_group):
2233 2233 from webhelpers.html import literal as _literal
2234 2234 _name = lambda k: _literal(cls.CHOICES_SEPARATOR.join(k))
2235 2235 return repo_group.group_id, _name(repo_group.full_path_splitted)
2236 2236
2237 2237 @classmethod
2238 2238 def groups_choices(cls, groups=None, show_empty_group=True):
2239 2239 if not groups:
2240 2240 groups = cls.query().all()
2241 2241
2242 2242 repo_groups = []
2243 2243 if show_empty_group:
2244 2244 repo_groups = [(-1, u'-- %s --' % _('No parent'))]
2245 2245
2246 2246 repo_groups.extend([cls._generate_choice(x) for x in groups])
2247 2247
2248 2248 repo_groups = sorted(
2249 2249 repo_groups, key=lambda t: t[1].split(cls.CHOICES_SEPARATOR)[0])
2250 2250 return repo_groups
2251 2251
2252 2252 @classmethod
2253 2253 def url_sep(cls):
2254 2254 return URL_SEP
2255 2255
2256 2256 @classmethod
2257 2257 def get_by_group_name(cls, group_name, cache=False, case_insensitive=False):
2258 2258 if case_insensitive:
2259 2259 gr = cls.query().filter(func.lower(cls.group_name)
2260 2260 == func.lower(group_name))
2261 2261 else:
2262 2262 gr = cls.query().filter(cls.group_name == group_name)
2263 2263 if cache:
2264 2264 name_key = _hash_key(group_name)
2265 2265 gr = gr.options(
2266 2266 FromCache("sql_cache_short", "get_group_%s" % name_key))
2267 2267 return gr.scalar()
2268 2268
2269 2269 @classmethod
2270 2270 def get_user_personal_repo_group(cls, user_id):
2271 2271 user = User.get(user_id)
2272 2272 if user.username == User.DEFAULT_USER:
2273 2273 return None
2274 2274
2275 2275 return cls.query()\
2276 2276 .filter(cls.personal == true()) \
2277 2277 .filter(cls.user == user).scalar()
2278 2278
2279 2279 @classmethod
2280 2280 def get_all_repo_groups(cls, user_id=Optional(None), group_id=Optional(None),
2281 2281 case_insensitive=True):
2282 2282 q = RepoGroup.query()
2283 2283
2284 2284 if not isinstance(user_id, Optional):
2285 2285 q = q.filter(RepoGroup.user_id == user_id)
2286 2286
2287 2287 if not isinstance(group_id, Optional):
2288 2288 q = q.filter(RepoGroup.group_parent_id == group_id)
2289 2289
2290 2290 if case_insensitive:
2291 2291 q = q.order_by(func.lower(RepoGroup.group_name))
2292 2292 else:
2293 2293 q = q.order_by(RepoGroup.group_name)
2294 2294 return q.all()
2295 2295
2296 2296 @property
2297 2297 def parents(self):
2298 2298 parents_recursion_limit = 10
2299 2299 groups = []
2300 2300 if self.parent_group is None:
2301 2301 return groups
2302 2302 cur_gr = self.parent_group
2303 2303 groups.insert(0, cur_gr)
2304 2304 cnt = 0
2305 2305 while 1:
2306 2306 cnt += 1
2307 2307 gr = getattr(cur_gr, 'parent_group', None)
2308 2308 cur_gr = cur_gr.parent_group
2309 2309 if gr is None:
2310 2310 break
2311 2311 if cnt == parents_recursion_limit:
2312 2312 # this will prevent accidental infinit loops
2313 2313 log.error(('more than %s parents found for group %s, stopping '
2314 2314 'recursive parent fetching' % (parents_recursion_limit, self)))
2315 2315 break
2316 2316
2317 2317 groups.insert(0, gr)
2318 2318 return groups
2319 2319
2320 2320 @property
2321 2321 def last_db_change(self):
2322 2322 return self.updated_on
2323 2323
2324 2324 @property
2325 2325 def children(self):
2326 2326 return RepoGroup.query().filter(RepoGroup.parent_group == self)
2327 2327
2328 2328 @property
2329 2329 def name(self):
2330 2330 return self.group_name.split(RepoGroup.url_sep())[-1]
2331 2331
2332 2332 @property
2333 2333 def full_path(self):
2334 2334 return self.group_name
2335 2335
2336 2336 @property
2337 2337 def full_path_splitted(self):
2338 2338 return self.group_name.split(RepoGroup.url_sep())
2339 2339
2340 2340 @property
2341 2341 def repositories(self):
2342 2342 return Repository.query()\
2343 2343 .filter(Repository.group == self)\
2344 2344 .order_by(Repository.repo_name)
2345 2345
2346 2346 @property
2347 2347 def repositories_recursive_count(self):
2348 2348 cnt = self.repositories.count()
2349 2349
2350 2350 def children_count(group):
2351 2351 cnt = 0
2352 2352 for child in group.children:
2353 2353 cnt += child.repositories.count()
2354 2354 cnt += children_count(child)
2355 2355 return cnt
2356 2356
2357 2357 return cnt + children_count(self)
2358 2358
2359 2359 def _recursive_objects(self, include_repos=True):
2360 2360 all_ = []
2361 2361
2362 2362 def _get_members(root_gr):
2363 2363 if include_repos:
2364 2364 for r in root_gr.repositories:
2365 2365 all_.append(r)
2366 2366 childs = root_gr.children.all()
2367 2367 if childs:
2368 2368 for gr in childs:
2369 2369 all_.append(gr)
2370 2370 _get_members(gr)
2371 2371
2372 2372 _get_members(self)
2373 2373 return [self] + all_
2374 2374
2375 2375 def recursive_groups_and_repos(self):
2376 2376 """
2377 2377 Recursive return all groups, with repositories in those groups
2378 2378 """
2379 2379 return self._recursive_objects()
2380 2380
2381 2381 def recursive_groups(self):
2382 2382 """
2383 2383 Returns all children groups for this group including children of children
2384 2384 """
2385 2385 return self._recursive_objects(include_repos=False)
2386 2386
2387 2387 def get_new_name(self, group_name):
2388 2388 """
2389 2389 returns new full group name based on parent and new name
2390 2390
2391 2391 :param group_name:
2392 2392 """
2393 2393 path_prefix = (self.parent_group.full_path_splitted if
2394 2394 self.parent_group else [])
2395 2395 return RepoGroup.url_sep().join(path_prefix + [group_name])
2396 2396
2397 2397 def permissions(self, with_admins=True, with_owner=True):
2398 2398 q = UserRepoGroupToPerm.query().filter(UserRepoGroupToPerm.group == self)
2399 2399 q = q.options(joinedload(UserRepoGroupToPerm.group),
2400 2400 joinedload(UserRepoGroupToPerm.user),
2401 2401 joinedload(UserRepoGroupToPerm.permission),)
2402 2402
2403 2403 # get owners and admins and permissions. We do a trick of re-writing
2404 2404 # objects from sqlalchemy to named-tuples due to sqlalchemy session
2405 2405 # has a global reference and changing one object propagates to all
2406 2406 # others. This means if admin is also an owner admin_row that change
2407 2407 # would propagate to both objects
2408 2408 perm_rows = []
2409 2409 for _usr in q.all():
2410 2410 usr = AttributeDict(_usr.user.get_dict())
2411 2411 usr.permission = _usr.permission.permission_name
2412 2412 perm_rows.append(usr)
2413 2413
2414 2414 # filter the perm rows by 'default' first and then sort them by
2415 2415 # admin,write,read,none permissions sorted again alphabetically in
2416 2416 # each group
2417 2417 perm_rows = sorted(perm_rows, key=display_sort)
2418 2418
2419 2419 _admin_perm = 'group.admin'
2420 2420 owner_row = []
2421 2421 if with_owner:
2422 2422 usr = AttributeDict(self.user.get_dict())
2423 2423 usr.owner_row = True
2424 2424 usr.permission = _admin_perm
2425 2425 owner_row.append(usr)
2426 2426
2427 2427 super_admin_rows = []
2428 2428 if with_admins:
2429 2429 for usr in User.get_all_super_admins():
2430 2430 # if this admin is also owner, don't double the record
2431 2431 if usr.user_id == owner_row[0].user_id:
2432 2432 owner_row[0].admin_row = True
2433 2433 else:
2434 2434 usr = AttributeDict(usr.get_dict())
2435 2435 usr.admin_row = True
2436 2436 usr.permission = _admin_perm
2437 2437 super_admin_rows.append(usr)
2438 2438
2439 2439 return super_admin_rows + owner_row + perm_rows
2440 2440
2441 2441 def permission_user_groups(self):
2442 2442 q = UserGroupRepoGroupToPerm.query().filter(UserGroupRepoGroupToPerm.group == self)
2443 2443 q = q.options(joinedload(UserGroupRepoGroupToPerm.group),
2444 2444 joinedload(UserGroupRepoGroupToPerm.users_group),
2445 2445 joinedload(UserGroupRepoGroupToPerm.permission),)
2446 2446
2447 2447 perm_rows = []
2448 2448 for _user_group in q.all():
2449 2449 usr = AttributeDict(_user_group.users_group.get_dict())
2450 2450 usr.permission = _user_group.permission.permission_name
2451 2451 perm_rows.append(usr)
2452 2452
2453 2453 return perm_rows
2454 2454
2455 2455 def get_api_data(self):
2456 2456 """
2457 2457 Common function for generating api data
2458 2458
2459 2459 """
2460 2460 group = self
2461 2461 data = {
2462 2462 'group_id': group.group_id,
2463 2463 'group_name': group.group_name,
2464 2464 'group_description': group.description_safe,
2465 2465 'parent_group': group.parent_group.group_name if group.parent_group else None,
2466 2466 'repositories': [x.repo_name for x in group.repositories],
2467 2467 'owner': group.user.username,
2468 2468 }
2469 2469 return data
2470 2470
2471 2471
2472 2472 class Permission(Base, BaseModel):
2473 2473 __tablename__ = 'permissions'
2474 2474 __table_args__ = (
2475 2475 Index('p_perm_name_idx', 'permission_name'),
2476 2476 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2477 2477 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
2478 2478 )
2479 2479 PERMS = [
2480 2480 ('hg.admin', _('RhodeCode Super Administrator')),
2481 2481
2482 2482 ('repository.none', _('Repository no access')),
2483 2483 ('repository.read', _('Repository read access')),
2484 2484 ('repository.write', _('Repository write access')),
2485 2485 ('repository.admin', _('Repository admin access')),
2486 2486
2487 2487 ('group.none', _('Repository group no access')),
2488 2488 ('group.read', _('Repository group read access')),
2489 2489 ('group.write', _('Repository group write access')),
2490 2490 ('group.admin', _('Repository group admin access')),
2491 2491
2492 2492 ('usergroup.none', _('User group no access')),
2493 2493 ('usergroup.read', _('User group read access')),
2494 2494 ('usergroup.write', _('User group write access')),
2495 2495 ('usergroup.admin', _('User group admin access')),
2496 2496
2497 2497 ('hg.repogroup.create.false', _('Repository Group creation disabled')),
2498 2498 ('hg.repogroup.create.true', _('Repository Group creation enabled')),
2499 2499
2500 2500 ('hg.usergroup.create.false', _('User Group creation disabled')),
2501 2501 ('hg.usergroup.create.true', _('User Group creation enabled')),
2502 2502
2503 2503 ('hg.create.none', _('Repository creation disabled')),
2504 2504 ('hg.create.repository', _('Repository creation enabled')),
2505 2505 ('hg.create.write_on_repogroup.true', _('Repository creation enabled with write permission to a repository group')),
2506 2506 ('hg.create.write_on_repogroup.false', _('Repository creation disabled with write permission to a repository group')),
2507 2507
2508 2508 ('hg.fork.none', _('Repository forking disabled')),
2509 2509 ('hg.fork.repository', _('Repository forking enabled')),
2510 2510
2511 2511 ('hg.register.none', _('Registration disabled')),
2512 2512 ('hg.register.manual_activate', _('User Registration with manual account activation')),
2513 2513 ('hg.register.auto_activate', _('User Registration with automatic account activation')),
2514 2514
2515 2515 ('hg.password_reset.enabled', _('Password reset enabled')),
2516 2516 ('hg.password_reset.hidden', _('Password reset hidden')),
2517 2517 ('hg.password_reset.disabled', _('Password reset disabled')),
2518 2518
2519 2519 ('hg.extern_activate.manual', _('Manual activation of external account')),
2520 2520 ('hg.extern_activate.auto', _('Automatic activation of external account')),
2521 2521
2522 2522 ('hg.inherit_default_perms.false', _('Inherit object permissions from default user disabled')),
2523 2523 ('hg.inherit_default_perms.true', _('Inherit object permissions from default user enabled')),
2524 2524 ]
2525 2525
2526 2526 # definition of system default permissions for DEFAULT user
2527 2527 DEFAULT_USER_PERMISSIONS = [
2528 2528 'repository.read',
2529 2529 'group.read',
2530 2530 'usergroup.read',
2531 2531 'hg.create.repository',
2532 2532 'hg.repogroup.create.false',
2533 2533 'hg.usergroup.create.false',
2534 2534 'hg.create.write_on_repogroup.true',
2535 2535 'hg.fork.repository',
2536 2536 'hg.register.manual_activate',
2537 2537 'hg.password_reset.enabled',
2538 2538 'hg.extern_activate.auto',
2539 2539 'hg.inherit_default_perms.true',
2540 2540 ]
2541 2541
2542 2542 # defines which permissions are more important higher the more important
2543 2543 # Weight defines which permissions are more important.
2544 2544 # The higher number the more important.
2545 2545 PERM_WEIGHTS = {
2546 2546 'repository.none': 0,
2547 2547 'repository.read': 1,
2548 2548 'repository.write': 3,
2549 2549 'repository.admin': 4,
2550 2550
2551 2551 'group.none': 0,
2552 2552 'group.read': 1,
2553 2553 'group.write': 3,
2554 2554 'group.admin': 4,
2555 2555
2556 2556 'usergroup.none': 0,
2557 2557 'usergroup.read': 1,
2558 2558 'usergroup.write': 3,
2559 2559 'usergroup.admin': 4,
2560 2560
2561 2561 'hg.repogroup.create.false': 0,
2562 2562 'hg.repogroup.create.true': 1,
2563 2563
2564 2564 'hg.usergroup.create.false': 0,
2565 2565 'hg.usergroup.create.true': 1,
2566 2566
2567 2567 'hg.fork.none': 0,
2568 2568 'hg.fork.repository': 1,
2569 2569 'hg.create.none': 0,
2570 2570 'hg.create.repository': 1
2571 2571 }
2572 2572
2573 2573 permission_id = Column("permission_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2574 2574 permission_name = Column("permission_name", String(255), nullable=True, unique=None, default=None)
2575 2575 permission_longname = Column("permission_longname", String(255), nullable=True, unique=None, default=None)
2576 2576
2577 2577 def __unicode__(self):
2578 2578 return u"<%s('%s:%s')>" % (
2579 2579 self.__class__.__name__, self.permission_id, self.permission_name
2580 2580 )
2581 2581
2582 2582 @classmethod
2583 2583 def get_by_key(cls, key):
2584 2584 return cls.query().filter(cls.permission_name == key).scalar()
2585 2585
2586 2586 @classmethod
2587 2587 def get_default_repo_perms(cls, user_id, repo_id=None):
2588 2588 q = Session().query(UserRepoToPerm, Repository, Permission)\
2589 2589 .join((Permission, UserRepoToPerm.permission_id == Permission.permission_id))\
2590 2590 .join((Repository, UserRepoToPerm.repository_id == Repository.repo_id))\
2591 2591 .filter(UserRepoToPerm.user_id == user_id)
2592 2592 if repo_id:
2593 2593 q = q.filter(UserRepoToPerm.repository_id == repo_id)
2594 2594 return q.all()
2595 2595
2596 2596 @classmethod
2597 2597 def get_default_repo_perms_from_user_group(cls, user_id, repo_id=None):
2598 2598 q = Session().query(UserGroupRepoToPerm, Repository, Permission)\
2599 2599 .join(
2600 2600 Permission,
2601 2601 UserGroupRepoToPerm.permission_id == Permission.permission_id)\
2602 2602 .join(
2603 2603 Repository,
2604 2604 UserGroupRepoToPerm.repository_id == Repository.repo_id)\
2605 2605 .join(
2606 2606 UserGroup,
2607 2607 UserGroupRepoToPerm.users_group_id ==
2608 2608 UserGroup.users_group_id)\
2609 2609 .join(
2610 2610 UserGroupMember,
2611 2611 UserGroupRepoToPerm.users_group_id ==
2612 2612 UserGroupMember.users_group_id)\
2613 2613 .filter(
2614 2614 UserGroupMember.user_id == user_id,
2615 2615 UserGroup.users_group_active == true())
2616 2616 if repo_id:
2617 2617 q = q.filter(UserGroupRepoToPerm.repository_id == repo_id)
2618 2618 return q.all()
2619 2619
2620 2620 @classmethod
2621 2621 def get_default_group_perms(cls, user_id, repo_group_id=None):
2622 2622 q = Session().query(UserRepoGroupToPerm, RepoGroup, Permission)\
2623 2623 .join((Permission, UserRepoGroupToPerm.permission_id == Permission.permission_id))\
2624 2624 .join((RepoGroup, UserRepoGroupToPerm.group_id == RepoGroup.group_id))\
2625 2625 .filter(UserRepoGroupToPerm.user_id == user_id)
2626 2626 if repo_group_id:
2627 2627 q = q.filter(UserRepoGroupToPerm.group_id == repo_group_id)
2628 2628 return q.all()
2629 2629
2630 2630 @classmethod
2631 2631 def get_default_group_perms_from_user_group(
2632 2632 cls, user_id, repo_group_id=None):
2633 2633 q = Session().query(UserGroupRepoGroupToPerm, RepoGroup, Permission)\
2634 2634 .join(
2635 2635 Permission,
2636 2636 UserGroupRepoGroupToPerm.permission_id ==
2637 2637 Permission.permission_id)\
2638 2638 .join(
2639 2639 RepoGroup,
2640 2640 UserGroupRepoGroupToPerm.group_id == RepoGroup.group_id)\
2641 2641 .join(
2642 2642 UserGroup,
2643 2643 UserGroupRepoGroupToPerm.users_group_id ==
2644 2644 UserGroup.users_group_id)\
2645 2645 .join(
2646 2646 UserGroupMember,
2647 2647 UserGroupRepoGroupToPerm.users_group_id ==
2648 2648 UserGroupMember.users_group_id)\
2649 2649 .filter(
2650 2650 UserGroupMember.user_id == user_id,
2651 2651 UserGroup.users_group_active == true())
2652 2652 if repo_group_id:
2653 2653 q = q.filter(UserGroupRepoGroupToPerm.group_id == repo_group_id)
2654 2654 return q.all()
2655 2655
2656 2656 @classmethod
2657 2657 def get_default_user_group_perms(cls, user_id, user_group_id=None):
2658 2658 q = Session().query(UserUserGroupToPerm, UserGroup, Permission)\
2659 2659 .join((Permission, UserUserGroupToPerm.permission_id == Permission.permission_id))\
2660 2660 .join((UserGroup, UserUserGroupToPerm.user_group_id == UserGroup.users_group_id))\
2661 2661 .filter(UserUserGroupToPerm.user_id == user_id)
2662 2662 if user_group_id:
2663 2663 q = q.filter(UserUserGroupToPerm.user_group_id == user_group_id)
2664 2664 return q.all()
2665 2665
2666 2666 @classmethod
2667 2667 def get_default_user_group_perms_from_user_group(
2668 2668 cls, user_id, user_group_id=None):
2669 2669 TargetUserGroup = aliased(UserGroup, name='target_user_group')
2670 2670 q = Session().query(UserGroupUserGroupToPerm, UserGroup, Permission)\
2671 2671 .join(
2672 2672 Permission,
2673 2673 UserGroupUserGroupToPerm.permission_id ==
2674 2674 Permission.permission_id)\
2675 2675 .join(
2676 2676 TargetUserGroup,
2677 2677 UserGroupUserGroupToPerm.target_user_group_id ==
2678 2678 TargetUserGroup.users_group_id)\
2679 2679 .join(
2680 2680 UserGroup,
2681 2681 UserGroupUserGroupToPerm.user_group_id ==
2682 2682 UserGroup.users_group_id)\
2683 2683 .join(
2684 2684 UserGroupMember,
2685 2685 UserGroupUserGroupToPerm.user_group_id ==
2686 2686 UserGroupMember.users_group_id)\
2687 2687 .filter(
2688 2688 UserGroupMember.user_id == user_id,
2689 2689 UserGroup.users_group_active == true())
2690 2690 if user_group_id:
2691 2691 q = q.filter(
2692 2692 UserGroupUserGroupToPerm.user_group_id == user_group_id)
2693 2693
2694 2694 return q.all()
2695 2695
2696 2696
2697 2697 class UserRepoToPerm(Base, BaseModel):
2698 2698 __tablename__ = 'repo_to_perm'
2699 2699 __table_args__ = (
2700 2700 UniqueConstraint('user_id', 'repository_id', 'permission_id'),
2701 2701 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2702 2702 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2703 2703 )
2704 2704 repo_to_perm_id = Column("repo_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2705 2705 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2706 2706 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2707 2707 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=None, default=None)
2708 2708
2709 2709 user = relationship('User')
2710 2710 repository = relationship('Repository')
2711 2711 permission = relationship('Permission')
2712 2712
2713 2713 @classmethod
2714 2714 def create(cls, user, repository, permission):
2715 2715 n = cls()
2716 2716 n.user = user
2717 2717 n.repository = repository
2718 2718 n.permission = permission
2719 2719 Session().add(n)
2720 2720 return n
2721 2721
2722 2722 def __unicode__(self):
2723 2723 return u'<%s => %s >' % (self.user, self.repository)
2724 2724
2725 2725
2726 2726 class UserUserGroupToPerm(Base, BaseModel):
2727 2727 __tablename__ = 'user_user_group_to_perm'
2728 2728 __table_args__ = (
2729 2729 UniqueConstraint('user_id', 'user_group_id', 'permission_id'),
2730 2730 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2731 2731 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2732 2732 )
2733 2733 user_user_group_to_perm_id = Column("user_user_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2734 2734 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2735 2735 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2736 2736 user_group_id = Column("user_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2737 2737
2738 2738 user = relationship('User')
2739 2739 user_group = relationship('UserGroup')
2740 2740 permission = relationship('Permission')
2741 2741
2742 2742 @classmethod
2743 2743 def create(cls, user, user_group, permission):
2744 2744 n = cls()
2745 2745 n.user = user
2746 2746 n.user_group = user_group
2747 2747 n.permission = permission
2748 2748 Session().add(n)
2749 2749 return n
2750 2750
2751 2751 def __unicode__(self):
2752 2752 return u'<%s => %s >' % (self.user, self.user_group)
2753 2753
2754 2754
2755 2755 class UserToPerm(Base, BaseModel):
2756 2756 __tablename__ = 'user_to_perm'
2757 2757 __table_args__ = (
2758 2758 UniqueConstraint('user_id', 'permission_id'),
2759 2759 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2760 2760 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2761 2761 )
2762 2762 user_to_perm_id = Column("user_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2763 2763 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2764 2764 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2765 2765
2766 2766 user = relationship('User')
2767 2767 permission = relationship('Permission', lazy='joined')
2768 2768
2769 2769 def __unicode__(self):
2770 2770 return u'<%s => %s >' % (self.user, self.permission)
2771 2771
2772 2772
2773 2773 class UserGroupRepoToPerm(Base, BaseModel):
2774 2774 __tablename__ = 'users_group_repo_to_perm'
2775 2775 __table_args__ = (
2776 2776 UniqueConstraint('repository_id', 'users_group_id', 'permission_id'),
2777 2777 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2778 2778 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2779 2779 )
2780 2780 users_group_to_perm_id = Column("users_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2781 2781 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2782 2782 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2783 2783 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=None, default=None)
2784 2784
2785 2785 users_group = relationship('UserGroup')
2786 2786 permission = relationship('Permission')
2787 2787 repository = relationship('Repository')
2788 2788
2789 2789 @classmethod
2790 2790 def create(cls, users_group, repository, permission):
2791 2791 n = cls()
2792 2792 n.users_group = users_group
2793 2793 n.repository = repository
2794 2794 n.permission = permission
2795 2795 Session().add(n)
2796 2796 return n
2797 2797
2798 2798 def __unicode__(self):
2799 2799 return u'<UserGroupRepoToPerm:%s => %s >' % (self.users_group, self.repository)
2800 2800
2801 2801
2802 2802 class UserGroupUserGroupToPerm(Base, BaseModel):
2803 2803 __tablename__ = 'user_group_user_group_to_perm'
2804 2804 __table_args__ = (
2805 2805 UniqueConstraint('target_user_group_id', 'user_group_id', 'permission_id'),
2806 2806 CheckConstraint('target_user_group_id != user_group_id'),
2807 2807 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2808 2808 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2809 2809 )
2810 2810 user_group_user_group_to_perm_id = Column("user_group_user_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2811 2811 target_user_group_id = Column("target_user_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2812 2812 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2813 2813 user_group_id = Column("user_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2814 2814
2815 2815 target_user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id')
2816 2816 user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.user_group_id==UserGroup.users_group_id')
2817 2817 permission = relationship('Permission')
2818 2818
2819 2819 @classmethod
2820 2820 def create(cls, target_user_group, user_group, permission):
2821 2821 n = cls()
2822 2822 n.target_user_group = target_user_group
2823 2823 n.user_group = user_group
2824 2824 n.permission = permission
2825 2825 Session().add(n)
2826 2826 return n
2827 2827
2828 2828 def __unicode__(self):
2829 2829 return u'<UserGroupUserGroup:%s => %s >' % (self.target_user_group, self.user_group)
2830 2830
2831 2831
2832 2832 class UserGroupToPerm(Base, BaseModel):
2833 2833 __tablename__ = 'users_group_to_perm'
2834 2834 __table_args__ = (
2835 2835 UniqueConstraint('users_group_id', 'permission_id',),
2836 2836 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2837 2837 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2838 2838 )
2839 2839 users_group_to_perm_id = Column("users_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2840 2840 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2841 2841 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2842 2842
2843 2843 users_group = relationship('UserGroup')
2844 2844 permission = relationship('Permission')
2845 2845
2846 2846
2847 2847 class UserRepoGroupToPerm(Base, BaseModel):
2848 2848 __tablename__ = 'user_repo_group_to_perm'
2849 2849 __table_args__ = (
2850 2850 UniqueConstraint('user_id', 'group_id', 'permission_id'),
2851 2851 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2852 2852 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2853 2853 )
2854 2854
2855 2855 group_to_perm_id = Column("group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2856 2856 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2857 2857 group_id = Column("group_id", Integer(), ForeignKey('groups.group_id'), nullable=False, unique=None, default=None)
2858 2858 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2859 2859
2860 2860 user = relationship('User')
2861 2861 group = relationship('RepoGroup')
2862 2862 permission = relationship('Permission')
2863 2863
2864 2864 @classmethod
2865 2865 def create(cls, user, repository_group, permission):
2866 2866 n = cls()
2867 2867 n.user = user
2868 2868 n.group = repository_group
2869 2869 n.permission = permission
2870 2870 Session().add(n)
2871 2871 return n
2872 2872
2873 2873
2874 2874 class UserGroupRepoGroupToPerm(Base, BaseModel):
2875 2875 __tablename__ = 'users_group_repo_group_to_perm'
2876 2876 __table_args__ = (
2877 2877 UniqueConstraint('users_group_id', 'group_id'),
2878 2878 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2879 2879 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2880 2880 )
2881 2881
2882 2882 users_group_repo_group_to_perm_id = Column("users_group_repo_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2883 2883 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2884 2884 group_id = Column("group_id", Integer(), ForeignKey('groups.group_id'), nullable=False, unique=None, default=None)
2885 2885 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2886 2886
2887 2887 users_group = relationship('UserGroup')
2888 2888 permission = relationship('Permission')
2889 2889 group = relationship('RepoGroup')
2890 2890
2891 2891 @classmethod
2892 2892 def create(cls, user_group, repository_group, permission):
2893 2893 n = cls()
2894 2894 n.users_group = user_group
2895 2895 n.group = repository_group
2896 2896 n.permission = permission
2897 2897 Session().add(n)
2898 2898 return n
2899 2899
2900 2900 def __unicode__(self):
2901 2901 return u'<UserGroupRepoGroupToPerm:%s => %s >' % (self.users_group, self.group)
2902 2902
2903 2903
2904 2904 class Statistics(Base, BaseModel):
2905 2905 __tablename__ = 'statistics'
2906 2906 __table_args__ = (
2907 2907 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2908 2908 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2909 2909 )
2910 2910 stat_id = Column("stat_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2911 2911 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=True, default=None)
2912 2912 stat_on_revision = Column("stat_on_revision", Integer(), nullable=False)
2913 2913 commit_activity = Column("commit_activity", LargeBinary(1000000), nullable=False)#JSON data
2914 2914 commit_activity_combined = Column("commit_activity_combined", LargeBinary(), nullable=False)#JSON data
2915 2915 languages = Column("languages", LargeBinary(1000000), nullable=False)#JSON data
2916 2916
2917 2917 repository = relationship('Repository', single_parent=True)
2918 2918
2919 2919
2920 2920 class UserFollowing(Base, BaseModel):
2921 2921 __tablename__ = 'user_followings'
2922 2922 __table_args__ = (
2923 2923 UniqueConstraint('user_id', 'follows_repository_id'),
2924 2924 UniqueConstraint('user_id', 'follows_user_id'),
2925 2925 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2926 2926 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2927 2927 )
2928 2928
2929 2929 user_following_id = Column("user_following_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2930 2930 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2931 2931 follows_repo_id = Column("follows_repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=True, unique=None, default=None)
2932 2932 follows_user_id = Column("follows_user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
2933 2933 follows_from = Column('follows_from', DateTime(timezone=False), nullable=True, unique=None, default=datetime.datetime.now)
2934 2934
2935 2935 user = relationship('User', primaryjoin='User.user_id==UserFollowing.user_id')
2936 2936
2937 2937 follows_user = relationship('User', primaryjoin='User.user_id==UserFollowing.follows_user_id')
2938 2938 follows_repository = relationship('Repository', order_by='Repository.repo_name')
2939 2939
2940 2940 @classmethod
2941 2941 def get_repo_followers(cls, repo_id):
2942 2942 return cls.query().filter(cls.follows_repo_id == repo_id)
2943 2943
2944 2944
2945 2945 class CacheKey(Base, BaseModel):
2946 2946 __tablename__ = 'cache_invalidation'
2947 2947 __table_args__ = (
2948 2948 UniqueConstraint('cache_key'),
2949 2949 Index('key_idx', 'cache_key'),
2950 2950 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2951 2951 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
2952 2952 )
2953 2953 CACHE_TYPE_ATOM = 'ATOM'
2954 2954 CACHE_TYPE_RSS = 'RSS'
2955 2955 CACHE_TYPE_README = 'README'
2956 2956
2957 2957 cache_id = Column("cache_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2958 2958 cache_key = Column("cache_key", String(255), nullable=True, unique=None, default=None)
2959 2959 cache_args = Column("cache_args", String(255), nullable=True, unique=None, default=None)
2960 2960 cache_active = Column("cache_active", Boolean(), nullable=True, unique=None, default=False)
2961 2961
2962 2962 def __init__(self, cache_key, cache_args=''):
2963 2963 self.cache_key = cache_key
2964 2964 self.cache_args = cache_args
2965 2965 self.cache_active = False
2966 2966
2967 2967 def __unicode__(self):
2968 2968 return u"<%s('%s:%s[%s]')>" % (
2969 2969 self.__class__.__name__,
2970 2970 self.cache_id, self.cache_key, self.cache_active)
2971 2971
2972 2972 def _cache_key_partition(self):
2973 2973 prefix, repo_name, suffix = self.cache_key.partition(self.cache_args)
2974 2974 return prefix, repo_name, suffix
2975 2975
2976 2976 def get_prefix(self):
2977 2977 """
2978 2978 Try to extract prefix from existing cache key. The key could consist
2979 2979 of prefix, repo_name, suffix
2980 2980 """
2981 2981 # this returns prefix, repo_name, suffix
2982 2982 return self._cache_key_partition()[0]
2983 2983
2984 2984 def get_suffix(self):
2985 2985 """
2986 2986 get suffix that might have been used in _get_cache_key to
2987 2987 generate self.cache_key. Only used for informational purposes
2988 2988 in repo_edit.mako.
2989 2989 """
2990 2990 # prefix, repo_name, suffix
2991 2991 return self._cache_key_partition()[2]
2992 2992
2993 2993 @classmethod
2994 2994 def delete_all_cache(cls):
2995 2995 """
2996 2996 Delete all cache keys from database.
2997 2997 Should only be run when all instances are down and all entries
2998 2998 thus stale.
2999 2999 """
3000 3000 cls.query().delete()
3001 3001 Session().commit()
3002 3002
3003 3003 @classmethod
3004 3004 def get_cache_key(cls, repo_name, cache_type):
3005 3005 """
3006 3006
3007 3007 Generate a cache key for this process of RhodeCode instance.
3008 3008 Prefix most likely will be process id or maybe explicitly set
3009 3009 instance_id from .ini file.
3010 3010 """
3011 3011 import rhodecode
3012 3012 prefix = safe_unicode(rhodecode.CONFIG.get('instance_id') or '')
3013 3013
3014 3014 repo_as_unicode = safe_unicode(repo_name)
3015 3015 key = u'{}_{}'.format(repo_as_unicode, cache_type) \
3016 3016 if cache_type else repo_as_unicode
3017 3017
3018 3018 return u'{}{}'.format(prefix, key)
3019 3019
3020 3020 @classmethod
3021 3021 def set_invalidate(cls, repo_name, delete=False):
3022 3022 """
3023 3023 Mark all caches of a repo as invalid in the database.
3024 3024 """
3025 3025
3026 3026 try:
3027 3027 qry = Session().query(cls).filter(cls.cache_args == repo_name)
3028 3028 if delete:
3029 3029 log.debug('cache objects deleted for repo %s',
3030 3030 safe_str(repo_name))
3031 3031 qry.delete()
3032 3032 else:
3033 3033 log.debug('cache objects marked as invalid for repo %s',
3034 3034 safe_str(repo_name))
3035 3035 qry.update({"cache_active": False})
3036 3036
3037 3037 Session().commit()
3038 3038 except Exception:
3039 3039 log.exception(
3040 3040 'Cache key invalidation failed for repository %s',
3041 3041 safe_str(repo_name))
3042 3042 Session().rollback()
3043 3043
3044 3044 @classmethod
3045 3045 def get_active_cache(cls, cache_key):
3046 3046 inv_obj = cls.query().filter(cls.cache_key == cache_key).scalar()
3047 3047 if inv_obj:
3048 3048 return inv_obj
3049 3049 return None
3050 3050
3051 3051 @classmethod
3052 3052 def repo_context_cache(cls, compute_func, repo_name, cache_type,
3053 3053 thread_scoped=False):
3054 3054 """
3055 3055 @cache_region('long_term')
3056 3056 def _heavy_calculation(cache_key):
3057 3057 return 'result'
3058 3058
3059 3059 cache_context = CacheKey.repo_context_cache(
3060 3060 _heavy_calculation, repo_name, cache_type)
3061 3061
3062 3062 with cache_context as context:
3063 3063 context.invalidate()
3064 3064 computed = context.compute()
3065 3065
3066 3066 assert computed == 'result'
3067 3067 """
3068 3068 from rhodecode.lib import caches
3069 3069 return caches.InvalidationContext(
3070 3070 compute_func, repo_name, cache_type, thread_scoped=thread_scoped)
3071 3071
3072 3072
3073 3073 class ChangesetComment(Base, BaseModel):
3074 3074 __tablename__ = 'changeset_comments'
3075 3075 __table_args__ = (
3076 3076 Index('cc_revision_idx', 'revision'),
3077 3077 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3078 3078 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3079 3079 )
3080 3080
3081 3081 COMMENT_OUTDATED = u'comment_outdated'
3082 3082 COMMENT_TYPE_NOTE = u'note'
3083 3083 COMMENT_TYPE_TODO = u'todo'
3084 3084 COMMENT_TYPES = [COMMENT_TYPE_NOTE, COMMENT_TYPE_TODO]
3085 3085
3086 3086 comment_id = Column('comment_id', Integer(), nullable=False, primary_key=True)
3087 3087 repo_id = Column('repo_id', Integer(), ForeignKey('repositories.repo_id'), nullable=False)
3088 3088 revision = Column('revision', String(40), nullable=True)
3089 3089 pull_request_id = Column("pull_request_id", Integer(), ForeignKey('pull_requests.pull_request_id'), nullable=True)
3090 3090 pull_request_version_id = Column("pull_request_version_id", Integer(), ForeignKey('pull_request_versions.pull_request_version_id'), nullable=True)
3091 3091 line_no = Column('line_no', Unicode(10), nullable=True)
3092 3092 hl_lines = Column('hl_lines', Unicode(512), nullable=True)
3093 3093 f_path = Column('f_path', Unicode(1000), nullable=True)
3094 3094 user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
3095 3095 text = Column('text', UnicodeText().with_variant(UnicodeText(25000), 'mysql'), nullable=False)
3096 3096 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3097 3097 modified_at = Column('modified_at', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3098 3098 renderer = Column('renderer', Unicode(64), nullable=True)
3099 3099 display_state = Column('display_state', Unicode(128), nullable=True)
3100 3100
3101 3101 comment_type = Column('comment_type', Unicode(128), nullable=True, default=COMMENT_TYPE_NOTE)
3102 3102 resolved_comment_id = Column('resolved_comment_id', Integer(), ForeignKey('changeset_comments.comment_id'), nullable=True)
3103 3103 resolved_comment = relationship('ChangesetComment', remote_side=comment_id, backref='resolved_by')
3104 3104 author = relationship('User', lazy='joined')
3105 3105 repo = relationship('Repository')
3106 3106 status_change = relationship('ChangesetStatus', cascade="all, delete, delete-orphan", lazy='joined')
3107 3107 pull_request = relationship('PullRequest', lazy='joined')
3108 3108 pull_request_version = relationship('PullRequestVersion')
3109 3109
3110 3110 @classmethod
3111 3111 def get_users(cls, revision=None, pull_request_id=None):
3112 3112 """
3113 3113 Returns user associated with this ChangesetComment. ie those
3114 3114 who actually commented
3115 3115
3116 3116 :param cls:
3117 3117 :param revision:
3118 3118 """
3119 3119 q = Session().query(User)\
3120 3120 .join(ChangesetComment.author)
3121 3121 if revision:
3122 3122 q = q.filter(cls.revision == revision)
3123 3123 elif pull_request_id:
3124 3124 q = q.filter(cls.pull_request_id == pull_request_id)
3125 3125 return q.all()
3126 3126
3127 3127 @classmethod
3128 3128 def get_index_from_version(cls, pr_version, versions):
3129 3129 num_versions = [x.pull_request_version_id for x in versions]
3130 3130 try:
3131 3131 return num_versions.index(pr_version) +1
3132 3132 except (IndexError, ValueError):
3133 3133 return
3134 3134
3135 3135 @property
3136 3136 def outdated(self):
3137 3137 return self.display_state == self.COMMENT_OUTDATED
3138 3138
3139 3139 def outdated_at_version(self, version):
3140 3140 """
3141 3141 Checks if comment is outdated for given pull request version
3142 3142 """
3143 3143 return self.outdated and self.pull_request_version_id != version
3144 3144
3145 3145 def older_than_version(self, version):
3146 3146 """
3147 3147 Checks if comment is made from previous version than given
3148 3148 """
3149 3149 if version is None:
3150 3150 return self.pull_request_version_id is not None
3151 3151
3152 3152 return self.pull_request_version_id < version
3153 3153
3154 3154 @property
3155 3155 def resolved(self):
3156 3156 return self.resolved_by[0] if self.resolved_by else None
3157 3157
3158 3158 @property
3159 3159 def is_todo(self):
3160 3160 return self.comment_type == self.COMMENT_TYPE_TODO
3161 3161
3162 3162 @property
3163 3163 def is_inline(self):
3164 3164 return self.line_no and self.f_path
3165 3165
3166 3166 def get_index_version(self, versions):
3167 3167 return self.get_index_from_version(
3168 3168 self.pull_request_version_id, versions)
3169 3169
3170 3170 def __repr__(self):
3171 3171 if self.comment_id:
3172 3172 return '<DB:Comment #%s>' % self.comment_id
3173 3173 else:
3174 3174 return '<DB:Comment at %#x>' % id(self)
3175 3175
3176 3176 def get_api_data(self):
3177 3177 comment = self
3178 3178 data = {
3179 3179 'comment_id': comment.comment_id,
3180 3180 'comment_type': comment.comment_type,
3181 3181 'comment_text': comment.text,
3182 3182 'comment_status': comment.status_change,
3183 3183 'comment_f_path': comment.f_path,
3184 3184 'comment_lineno': comment.line_no,
3185 3185 'comment_author': comment.author,
3186 3186 'comment_created_on': comment.created_on
3187 3187 }
3188 3188 return data
3189 3189
3190 3190 def __json__(self):
3191 3191 data = dict()
3192 3192 data.update(self.get_api_data())
3193 3193 return data
3194 3194
3195 3195
3196 3196 class ChangesetStatus(Base, BaseModel):
3197 3197 __tablename__ = 'changeset_statuses'
3198 3198 __table_args__ = (
3199 3199 Index('cs_revision_idx', 'revision'),
3200 3200 Index('cs_version_idx', 'version'),
3201 3201 UniqueConstraint('repo_id', 'revision', 'version'),
3202 3202 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3203 3203 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3204 3204 )
3205 3205 STATUS_NOT_REVIEWED = DEFAULT = 'not_reviewed'
3206 3206 STATUS_APPROVED = 'approved'
3207 3207 STATUS_REJECTED = 'rejected'
3208 3208 STATUS_UNDER_REVIEW = 'under_review'
3209 3209
3210 3210 STATUSES = [
3211 3211 (STATUS_NOT_REVIEWED, _("Not Reviewed")), # (no icon) and default
3212 3212 (STATUS_APPROVED, _("Approved")),
3213 3213 (STATUS_REJECTED, _("Rejected")),
3214 3214 (STATUS_UNDER_REVIEW, _("Under Review")),
3215 3215 ]
3216 3216
3217 3217 changeset_status_id = Column('changeset_status_id', Integer(), nullable=False, primary_key=True)
3218 3218 repo_id = Column('repo_id', Integer(), ForeignKey('repositories.repo_id'), nullable=False)
3219 3219 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None)
3220 3220 revision = Column('revision', String(40), nullable=False)
3221 3221 status = Column('status', String(128), nullable=False, default=DEFAULT)
3222 3222 changeset_comment_id = Column('changeset_comment_id', Integer(), ForeignKey('changeset_comments.comment_id'))
3223 3223 modified_at = Column('modified_at', DateTime(), nullable=False, default=datetime.datetime.now)
3224 3224 version = Column('version', Integer(), nullable=False, default=0)
3225 3225 pull_request_id = Column("pull_request_id", Integer(), ForeignKey('pull_requests.pull_request_id'), nullable=True)
3226 3226
3227 3227 author = relationship('User', lazy='joined')
3228 3228 repo = relationship('Repository')
3229 3229 comment = relationship('ChangesetComment', lazy='joined')
3230 3230 pull_request = relationship('PullRequest', lazy='joined')
3231 3231
3232 3232 def __unicode__(self):
3233 3233 return u"<%s('%s[v%s]:%s')>" % (
3234 3234 self.__class__.__name__,
3235 3235 self.status, self.version, self.author
3236 3236 )
3237 3237
3238 3238 @classmethod
3239 3239 def get_status_lbl(cls, value):
3240 3240 return dict(cls.STATUSES).get(value)
3241 3241
3242 3242 @property
3243 3243 def status_lbl(self):
3244 3244 return ChangesetStatus.get_status_lbl(self.status)
3245 3245
3246 3246 def get_api_data(self):
3247 3247 status = self
3248 3248 data = {
3249 3249 'status_id': status.changeset_status_id,
3250 3250 'status': status.status,
3251 3251 }
3252 3252 return data
3253 3253
3254 3254 def __json__(self):
3255 3255 data = dict()
3256 3256 data.update(self.get_api_data())
3257 3257 return data
3258 3258
3259 3259
3260 3260 class _PullRequestBase(BaseModel):
3261 3261 """
3262 3262 Common attributes of pull request and version entries.
3263 3263 """
3264 3264
3265 3265 # .status values
3266 3266 STATUS_NEW = u'new'
3267 3267 STATUS_OPEN = u'open'
3268 3268 STATUS_CLOSED = u'closed'
3269 3269
3270 3270 title = Column('title', Unicode(255), nullable=True)
3271 3271 description = Column(
3272 3272 'description', UnicodeText().with_variant(UnicodeText(10240), 'mysql'),
3273 3273 nullable=True)
3274 3274 # new/open/closed status of pull request (not approve/reject/etc)
3275 3275 status = Column('status', Unicode(255), nullable=False, default=STATUS_NEW)
3276 3276 created_on = Column(
3277 3277 'created_on', DateTime(timezone=False), nullable=False,
3278 3278 default=datetime.datetime.now)
3279 3279 updated_on = Column(
3280 3280 'updated_on', DateTime(timezone=False), nullable=False,
3281 3281 default=datetime.datetime.now)
3282 3282
3283 3283 @declared_attr
3284 3284 def user_id(cls):
3285 3285 return Column(
3286 3286 "user_id", Integer(), ForeignKey('users.user_id'), nullable=False,
3287 3287 unique=None)
3288 3288
3289 3289 # 500 revisions max
3290 3290 _revisions = Column(
3291 3291 'revisions', UnicodeText().with_variant(UnicodeText(20500), 'mysql'))
3292 3292
3293 3293 @declared_attr
3294 3294 def source_repo_id(cls):
3295 3295 # TODO: dan: rename column to source_repo_id
3296 3296 return Column(
3297 3297 'org_repo_id', Integer(), ForeignKey('repositories.repo_id'),
3298 3298 nullable=False)
3299 3299
3300 3300 source_ref = Column('org_ref', Unicode(255), nullable=False)
3301 3301
3302 3302 @declared_attr
3303 3303 def target_repo_id(cls):
3304 3304 # TODO: dan: rename column to target_repo_id
3305 3305 return Column(
3306 3306 'other_repo_id', Integer(), ForeignKey('repositories.repo_id'),
3307 3307 nullable=False)
3308 3308
3309 3309 target_ref = Column('other_ref', Unicode(255), nullable=False)
3310 3310 _shadow_merge_ref = Column('shadow_merge_ref', Unicode(255), nullable=True)
3311 3311
3312 3312 # TODO: dan: rename column to last_merge_source_rev
3313 3313 _last_merge_source_rev = Column(
3314 3314 'last_merge_org_rev', String(40), nullable=True)
3315 3315 # TODO: dan: rename column to last_merge_target_rev
3316 3316 _last_merge_target_rev = Column(
3317 3317 'last_merge_other_rev', String(40), nullable=True)
3318 3318 _last_merge_status = Column('merge_status', Integer(), nullable=True)
3319 3319 merge_rev = Column('merge_rev', String(40), nullable=True)
3320 3320
3321 3321 reviewer_data = Column(
3322 3322 'reviewer_data_json', MutationObj.as_mutable(
3323 3323 JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
3324 3324
3325 3325 @property
3326 3326 def reviewer_data_json(self):
3327 3327 return json.dumps(self.reviewer_data)
3328 3328
3329 3329 @hybrid_property
3330 3330 def description_safe(self):
3331 3331 from rhodecode.lib import helpers as h
3332 3332 return h.escape(self.description)
3333 3333
3334 3334 @hybrid_property
3335 3335 def revisions(self):
3336 3336 return self._revisions.split(':') if self._revisions else []
3337 3337
3338 3338 @revisions.setter
3339 3339 def revisions(self, val):
3340 3340 self._revisions = ':'.join(val)
3341 3341
3342 @hybrid_property
3343 def last_merge_status(self):
3344 return safe_int(self._last_merge_status)
3345
3346 @last_merge_status.setter
3347 def last_merge_status(self, val):
3348 self._last_merge_status = val
3349
3342 3350 @declared_attr
3343 3351 def author(cls):
3344 3352 return relationship('User', lazy='joined')
3345 3353
3346 3354 @declared_attr
3347 3355 def source_repo(cls):
3348 3356 return relationship(
3349 3357 'Repository',
3350 3358 primaryjoin='%s.source_repo_id==Repository.repo_id' % cls.__name__)
3351 3359
3352 3360 @property
3353 3361 def source_ref_parts(self):
3354 3362 return self.unicode_to_reference(self.source_ref)
3355 3363
3356 3364 @declared_attr
3357 3365 def target_repo(cls):
3358 3366 return relationship(
3359 3367 'Repository',
3360 3368 primaryjoin='%s.target_repo_id==Repository.repo_id' % cls.__name__)
3361 3369
3362 3370 @property
3363 3371 def target_ref_parts(self):
3364 3372 return self.unicode_to_reference(self.target_ref)
3365 3373
3366 3374 @property
3367 3375 def shadow_merge_ref(self):
3368 3376 return self.unicode_to_reference(self._shadow_merge_ref)
3369 3377
3370 3378 @shadow_merge_ref.setter
3371 3379 def shadow_merge_ref(self, ref):
3372 3380 self._shadow_merge_ref = self.reference_to_unicode(ref)
3373 3381
3374 3382 def unicode_to_reference(self, raw):
3375 3383 """
3376 3384 Convert a unicode (or string) to a reference object.
3377 3385 If unicode evaluates to False it returns None.
3378 3386 """
3379 3387 if raw:
3380 3388 refs = raw.split(':')
3381 3389 return Reference(*refs)
3382 3390 else:
3383 3391 return None
3384 3392
3385 3393 def reference_to_unicode(self, ref):
3386 3394 """
3387 3395 Convert a reference object to unicode.
3388 3396 If reference is None it returns None.
3389 3397 """
3390 3398 if ref:
3391 3399 return u':'.join(ref)
3392 3400 else:
3393 3401 return None
3394 3402
3395 3403 def get_api_data(self, with_merge_state=True):
3396 3404 from rhodecode.model.pull_request import PullRequestModel
3397 3405
3398 3406 pull_request = self
3399 3407 if with_merge_state:
3400 3408 merge_status = PullRequestModel().merge_status(pull_request)
3401 3409 merge_state = {
3402 3410 'status': merge_status[0],
3403 3411 'message': safe_unicode(merge_status[1]),
3404 3412 }
3405 3413 else:
3406 3414 merge_state = {'status': 'not_available',
3407 3415 'message': 'not_available'}
3408 3416
3409 3417 merge_data = {
3410 3418 'clone_url': PullRequestModel().get_shadow_clone_url(pull_request),
3411 3419 'reference': (
3412 3420 pull_request.shadow_merge_ref._asdict()
3413 3421 if pull_request.shadow_merge_ref else None),
3414 3422 }
3415 3423
3416 3424 data = {
3417 3425 'pull_request_id': pull_request.pull_request_id,
3418 3426 'url': PullRequestModel().get_url(pull_request),
3419 3427 'title': pull_request.title,
3420 3428 'description': pull_request.description,
3421 3429 'status': pull_request.status,
3422 3430 'created_on': pull_request.created_on,
3423 3431 'updated_on': pull_request.updated_on,
3424 3432 'commit_ids': pull_request.revisions,
3425 3433 'review_status': pull_request.calculated_review_status(),
3426 3434 'mergeable': merge_state,
3427 3435 'source': {
3428 3436 'clone_url': pull_request.source_repo.clone_url(),
3429 3437 'repository': pull_request.source_repo.repo_name,
3430 3438 'reference': {
3431 3439 'name': pull_request.source_ref_parts.name,
3432 3440 'type': pull_request.source_ref_parts.type,
3433 3441 'commit_id': pull_request.source_ref_parts.commit_id,
3434 3442 },
3435 3443 },
3436 3444 'target': {
3437 3445 'clone_url': pull_request.target_repo.clone_url(),
3438 3446 'repository': pull_request.target_repo.repo_name,
3439 3447 'reference': {
3440 3448 'name': pull_request.target_ref_parts.name,
3441 3449 'type': pull_request.target_ref_parts.type,
3442 3450 'commit_id': pull_request.target_ref_parts.commit_id,
3443 3451 },
3444 3452 },
3445 3453 'merge': merge_data,
3446 3454 'author': pull_request.author.get_api_data(include_secrets=False,
3447 3455 details='basic'),
3448 3456 'reviewers': [
3449 3457 {
3450 3458 'user': reviewer.get_api_data(include_secrets=False,
3451 3459 details='basic'),
3452 3460 'reasons': reasons,
3453 3461 'review_status': st[0][1].status if st else 'not_reviewed',
3454 3462 }
3455 3463 for reviewer, reasons, mandatory, st in
3456 3464 pull_request.reviewers_statuses()
3457 3465 ]
3458 3466 }
3459 3467
3460 3468 return data
3461 3469
3462 3470
3463 3471 class PullRequest(Base, _PullRequestBase):
3464 3472 __tablename__ = 'pull_requests'
3465 3473 __table_args__ = (
3466 3474 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3467 3475 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3468 3476 )
3469 3477
3470 3478 pull_request_id = Column(
3471 3479 'pull_request_id', Integer(), nullable=False, primary_key=True)
3472 3480
3473 3481 def __repr__(self):
3474 3482 if self.pull_request_id:
3475 3483 return '<DB:PullRequest #%s>' % self.pull_request_id
3476 3484 else:
3477 3485 return '<DB:PullRequest at %#x>' % id(self)
3478 3486
3479 3487 reviewers = relationship('PullRequestReviewers',
3480 3488 cascade="all, delete, delete-orphan")
3481 3489 statuses = relationship('ChangesetStatus',
3482 3490 cascade="all, delete, delete-orphan")
3483 3491 comments = relationship('ChangesetComment',
3484 3492 cascade="all, delete, delete-orphan")
3485 3493 versions = relationship('PullRequestVersion',
3486 3494 cascade="all, delete, delete-orphan",
3487 3495 lazy='dynamic')
3488 3496
3489 3497 @classmethod
3490 3498 def get_pr_display_object(cls, pull_request_obj, org_pull_request_obj,
3491 3499 internal_methods=None):
3492 3500
3493 3501 class PullRequestDisplay(object):
3494 3502 """
3495 3503 Special object wrapper for showing PullRequest data via Versions
3496 3504 It mimics PR object as close as possible. This is read only object
3497 3505 just for display
3498 3506 """
3499 3507
3500 3508 def __init__(self, attrs, internal=None):
3501 3509 self.attrs = attrs
3502 3510 # internal have priority over the given ones via attrs
3503 3511 self.internal = internal or ['versions']
3504 3512
3505 3513 def __getattr__(self, item):
3506 3514 if item in self.internal:
3507 3515 return getattr(self, item)
3508 3516 try:
3509 3517 return self.attrs[item]
3510 3518 except KeyError:
3511 3519 raise AttributeError(
3512 3520 '%s object has no attribute %s' % (self, item))
3513 3521
3514 3522 def __repr__(self):
3515 3523 return '<DB:PullRequestDisplay #%s>' % self.attrs.get('pull_request_id')
3516 3524
3517 3525 def versions(self):
3518 3526 return pull_request_obj.versions.order_by(
3519 3527 PullRequestVersion.pull_request_version_id).all()
3520 3528
3521 3529 def is_closed(self):
3522 3530 return pull_request_obj.is_closed()
3523 3531
3524 3532 @property
3525 3533 def pull_request_version_id(self):
3526 3534 return getattr(pull_request_obj, 'pull_request_version_id', None)
3527 3535
3528 3536 attrs = StrictAttributeDict(pull_request_obj.get_api_data())
3529 3537
3530 3538 attrs.author = StrictAttributeDict(
3531 3539 pull_request_obj.author.get_api_data())
3532 3540 if pull_request_obj.target_repo:
3533 3541 attrs.target_repo = StrictAttributeDict(
3534 3542 pull_request_obj.target_repo.get_api_data())
3535 3543 attrs.target_repo.clone_url = pull_request_obj.target_repo.clone_url
3536 3544
3537 3545 if pull_request_obj.source_repo:
3538 3546 attrs.source_repo = StrictAttributeDict(
3539 3547 pull_request_obj.source_repo.get_api_data())
3540 3548 attrs.source_repo.clone_url = pull_request_obj.source_repo.clone_url
3541 3549
3542 3550 attrs.source_ref_parts = pull_request_obj.source_ref_parts
3543 3551 attrs.target_ref_parts = pull_request_obj.target_ref_parts
3544 3552 attrs.revisions = pull_request_obj.revisions
3545 3553
3546 3554 attrs.shadow_merge_ref = org_pull_request_obj.shadow_merge_ref
3547 3555 attrs.reviewer_data = org_pull_request_obj.reviewer_data
3548 3556 attrs.reviewer_data_json = org_pull_request_obj.reviewer_data_json
3549 3557
3550 3558 return PullRequestDisplay(attrs, internal=internal_methods)
3551 3559
3552 3560 def is_closed(self):
3553 3561 return self.status == self.STATUS_CLOSED
3554 3562
3555 3563 def __json__(self):
3556 3564 return {
3557 3565 'revisions': self.revisions,
3558 3566 }
3559 3567
3560 3568 def calculated_review_status(self):
3561 3569 from rhodecode.model.changeset_status import ChangesetStatusModel
3562 3570 return ChangesetStatusModel().calculated_review_status(self)
3563 3571
3564 3572 def reviewers_statuses(self):
3565 3573 from rhodecode.model.changeset_status import ChangesetStatusModel
3566 3574 return ChangesetStatusModel().reviewers_statuses(self)
3567 3575
3568 3576 @property
3569 3577 def workspace_id(self):
3570 3578 from rhodecode.model.pull_request import PullRequestModel
3571 3579 return PullRequestModel()._workspace_id(self)
3572 3580
3573 3581 def get_shadow_repo(self):
3574 3582 workspace_id = self.workspace_id
3575 3583 vcs_obj = self.target_repo.scm_instance()
3576 3584 shadow_repository_path = vcs_obj._get_shadow_repository_path(
3577 3585 workspace_id)
3578 3586 return vcs_obj._get_shadow_instance(shadow_repository_path)
3579 3587
3580 3588
3581 3589 class PullRequestVersion(Base, _PullRequestBase):
3582 3590 __tablename__ = 'pull_request_versions'
3583 3591 __table_args__ = (
3584 3592 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3585 3593 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3586 3594 )
3587 3595
3588 3596 pull_request_version_id = Column(
3589 3597 'pull_request_version_id', Integer(), nullable=False, primary_key=True)
3590 3598 pull_request_id = Column(
3591 3599 'pull_request_id', Integer(),
3592 3600 ForeignKey('pull_requests.pull_request_id'), nullable=False)
3593 3601 pull_request = relationship('PullRequest')
3594 3602
3595 3603 def __repr__(self):
3596 3604 if self.pull_request_version_id:
3597 3605 return '<DB:PullRequestVersion #%s>' % self.pull_request_version_id
3598 3606 else:
3599 3607 return '<DB:PullRequestVersion at %#x>' % id(self)
3600 3608
3601 3609 @property
3602 3610 def reviewers(self):
3603 3611 return self.pull_request.reviewers
3604 3612
3605 3613 @property
3606 3614 def versions(self):
3607 3615 return self.pull_request.versions
3608 3616
3609 3617 def is_closed(self):
3610 3618 # calculate from original
3611 3619 return self.pull_request.status == self.STATUS_CLOSED
3612 3620
3613 3621 def calculated_review_status(self):
3614 3622 return self.pull_request.calculated_review_status()
3615 3623
3616 3624 def reviewers_statuses(self):
3617 3625 return self.pull_request.reviewers_statuses()
3618 3626
3619 3627
3620 3628 class PullRequestReviewers(Base, BaseModel):
3621 3629 __tablename__ = 'pull_request_reviewers'
3622 3630 __table_args__ = (
3623 3631 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3624 3632 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3625 3633 )
3626 3634
3627 3635 @hybrid_property
3628 3636 def reasons(self):
3629 3637 if not self._reasons:
3630 3638 return []
3631 3639 return self._reasons
3632 3640
3633 3641 @reasons.setter
3634 3642 def reasons(self, val):
3635 3643 val = val or []
3636 3644 if any(not isinstance(x, basestring) for x in val):
3637 3645 raise Exception('invalid reasons type, must be list of strings')
3638 3646 self._reasons = val
3639 3647
3640 3648 pull_requests_reviewers_id = Column(
3641 3649 'pull_requests_reviewers_id', Integer(), nullable=False,
3642 3650 primary_key=True)
3643 3651 pull_request_id = Column(
3644 3652 "pull_request_id", Integer(),
3645 3653 ForeignKey('pull_requests.pull_request_id'), nullable=False)
3646 3654 user_id = Column(
3647 3655 "user_id", Integer(), ForeignKey('users.user_id'), nullable=True)
3648 3656 _reasons = Column(
3649 3657 'reason', MutationList.as_mutable(
3650 3658 JsonType('list', dialect_map=dict(mysql=UnicodeText(16384)))))
3651 3659 mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
3652 3660 user = relationship('User')
3653 3661 pull_request = relationship('PullRequest')
3654 3662
3655 3663
3656 3664 class Notification(Base, BaseModel):
3657 3665 __tablename__ = 'notifications'
3658 3666 __table_args__ = (
3659 3667 Index('notification_type_idx', 'type'),
3660 3668 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3661 3669 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3662 3670 )
3663 3671
3664 3672 TYPE_CHANGESET_COMMENT = u'cs_comment'
3665 3673 TYPE_MESSAGE = u'message'
3666 3674 TYPE_MENTION = u'mention'
3667 3675 TYPE_REGISTRATION = u'registration'
3668 3676 TYPE_PULL_REQUEST = u'pull_request'
3669 3677 TYPE_PULL_REQUEST_COMMENT = u'pull_request_comment'
3670 3678
3671 3679 notification_id = Column('notification_id', Integer(), nullable=False, primary_key=True)
3672 3680 subject = Column('subject', Unicode(512), nullable=True)
3673 3681 body = Column('body', UnicodeText().with_variant(UnicodeText(50000), 'mysql'), nullable=True)
3674 3682 created_by = Column("created_by", Integer(), ForeignKey('users.user_id'), nullable=True)
3675 3683 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3676 3684 type_ = Column('type', Unicode(255))
3677 3685
3678 3686 created_by_user = relationship('User')
3679 3687 notifications_to_users = relationship('UserNotification', lazy='joined',
3680 3688 cascade="all, delete, delete-orphan")
3681 3689
3682 3690 @property
3683 3691 def recipients(self):
3684 3692 return [x.user for x in UserNotification.query()\
3685 3693 .filter(UserNotification.notification == self)\
3686 3694 .order_by(UserNotification.user_id.asc()).all()]
3687 3695
3688 3696 @classmethod
3689 3697 def create(cls, created_by, subject, body, recipients, type_=None):
3690 3698 if type_ is None:
3691 3699 type_ = Notification.TYPE_MESSAGE
3692 3700
3693 3701 notification = cls()
3694 3702 notification.created_by_user = created_by
3695 3703 notification.subject = subject
3696 3704 notification.body = body
3697 3705 notification.type_ = type_
3698 3706 notification.created_on = datetime.datetime.now()
3699 3707
3700 3708 for u in recipients:
3701 3709 assoc = UserNotification()
3702 3710 assoc.notification = notification
3703 3711
3704 3712 # if created_by is inside recipients mark his notification
3705 3713 # as read
3706 3714 if u.user_id == created_by.user_id:
3707 3715 assoc.read = True
3708 3716
3709 3717 u.notifications.append(assoc)
3710 3718 Session().add(notification)
3711 3719
3712 3720 return notification
3713 3721
3714 3722
3715 3723 class UserNotification(Base, BaseModel):
3716 3724 __tablename__ = 'user_to_notification'
3717 3725 __table_args__ = (
3718 3726 UniqueConstraint('user_id', 'notification_id'),
3719 3727 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3720 3728 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3721 3729 )
3722 3730 user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), primary_key=True)
3723 3731 notification_id = Column("notification_id", Integer(), ForeignKey('notifications.notification_id'), primary_key=True)
3724 3732 read = Column('read', Boolean, default=False)
3725 3733 sent_on = Column('sent_on', DateTime(timezone=False), nullable=True, unique=None)
3726 3734
3727 3735 user = relationship('User', lazy="joined")
3728 3736 notification = relationship('Notification', lazy="joined",
3729 3737 order_by=lambda: Notification.created_on.desc(),)
3730 3738
3731 3739 def mark_as_read(self):
3732 3740 self.read = True
3733 3741 Session().add(self)
3734 3742
3735 3743
3736 3744 class Gist(Base, BaseModel):
3737 3745 __tablename__ = 'gists'
3738 3746 __table_args__ = (
3739 3747 Index('g_gist_access_id_idx', 'gist_access_id'),
3740 3748 Index('g_created_on_idx', 'created_on'),
3741 3749 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3742 3750 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3743 3751 )
3744 3752 GIST_PUBLIC = u'public'
3745 3753 GIST_PRIVATE = u'private'
3746 3754 DEFAULT_FILENAME = u'gistfile1.txt'
3747 3755
3748 3756 ACL_LEVEL_PUBLIC = u'acl_public'
3749 3757 ACL_LEVEL_PRIVATE = u'acl_private'
3750 3758
3751 3759 gist_id = Column('gist_id', Integer(), primary_key=True)
3752 3760 gist_access_id = Column('gist_access_id', Unicode(250))
3753 3761 gist_description = Column('gist_description', UnicodeText().with_variant(UnicodeText(1024), 'mysql'))
3754 3762 gist_owner = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=True)
3755 3763 gist_expires = Column('gist_expires', Float(53), nullable=False)
3756 3764 gist_type = Column('gist_type', Unicode(128), nullable=False)
3757 3765 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3758 3766 modified_at = Column('modified_at', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3759 3767 acl_level = Column('acl_level', Unicode(128), nullable=True)
3760 3768
3761 3769 owner = relationship('User')
3762 3770
3763 3771 def __repr__(self):
3764 3772 return '<Gist:[%s]%s>' % (self.gist_type, self.gist_access_id)
3765 3773
3766 3774 @hybrid_property
3767 3775 def description_safe(self):
3768 3776 from rhodecode.lib import helpers as h
3769 3777 return h.escape(self.gist_description)
3770 3778
3771 3779 @classmethod
3772 3780 def get_or_404(cls, id_):
3773 3781 from pyramid.httpexceptions import HTTPNotFound
3774 3782
3775 3783 res = cls.query().filter(cls.gist_access_id == id_).scalar()
3776 3784 if not res:
3777 3785 raise HTTPNotFound()
3778 3786 return res
3779 3787
3780 3788 @classmethod
3781 3789 def get_by_access_id(cls, gist_access_id):
3782 3790 return cls.query().filter(cls.gist_access_id == gist_access_id).scalar()
3783 3791
3784 3792 def gist_url(self):
3785 3793 from rhodecode.model.gist import GistModel
3786 3794 return GistModel().get_url(self)
3787 3795
3788 3796 @classmethod
3789 3797 def base_path(cls):
3790 3798 """
3791 3799 Returns base path when all gists are stored
3792 3800
3793 3801 :param cls:
3794 3802 """
3795 3803 from rhodecode.model.gist import GIST_STORE_LOC
3796 3804 q = Session().query(RhodeCodeUi)\
3797 3805 .filter(RhodeCodeUi.ui_key == URL_SEP)
3798 3806 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
3799 3807 return os.path.join(q.one().ui_value, GIST_STORE_LOC)
3800 3808
3801 3809 def get_api_data(self):
3802 3810 """
3803 3811 Common function for generating gist related data for API
3804 3812 """
3805 3813 gist = self
3806 3814 data = {
3807 3815 'gist_id': gist.gist_id,
3808 3816 'type': gist.gist_type,
3809 3817 'access_id': gist.gist_access_id,
3810 3818 'description': gist.gist_description,
3811 3819 'url': gist.gist_url(),
3812 3820 'expires': gist.gist_expires,
3813 3821 'created_on': gist.created_on,
3814 3822 'modified_at': gist.modified_at,
3815 3823 'content': None,
3816 3824 'acl_level': gist.acl_level,
3817 3825 }
3818 3826 return data
3819 3827
3820 3828 def __json__(self):
3821 3829 data = dict(
3822 3830 )
3823 3831 data.update(self.get_api_data())
3824 3832 return data
3825 3833 # SCM functions
3826 3834
3827 3835 def scm_instance(self, **kwargs):
3828 3836 full_repo_path = os.path.join(self.base_path(), self.gist_access_id)
3829 3837 return get_vcs_instance(
3830 3838 repo_path=safe_str(full_repo_path), create=False)
3831 3839
3832 3840
3833 3841 class ExternalIdentity(Base, BaseModel):
3834 3842 __tablename__ = 'external_identities'
3835 3843 __table_args__ = (
3836 3844 Index('local_user_id_idx', 'local_user_id'),
3837 3845 Index('external_id_idx', 'external_id'),
3838 3846 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3839 3847 'mysql_charset': 'utf8'})
3840 3848
3841 3849 external_id = Column('external_id', Unicode(255), default=u'',
3842 3850 primary_key=True)
3843 3851 external_username = Column('external_username', Unicode(1024), default=u'')
3844 3852 local_user_id = Column('local_user_id', Integer(),
3845 3853 ForeignKey('users.user_id'), primary_key=True)
3846 3854 provider_name = Column('provider_name', Unicode(255), default=u'',
3847 3855 primary_key=True)
3848 3856 access_token = Column('access_token', String(1024), default=u'')
3849 3857 alt_token = Column('alt_token', String(1024), default=u'')
3850 3858 token_secret = Column('token_secret', String(1024), default=u'')
3851 3859
3852 3860 @classmethod
3853 3861 def by_external_id_and_provider(cls, external_id, provider_name,
3854 3862 local_user_id=None):
3855 3863 """
3856 3864 Returns ExternalIdentity instance based on search params
3857 3865
3858 3866 :param external_id:
3859 3867 :param provider_name:
3860 3868 :return: ExternalIdentity
3861 3869 """
3862 3870 query = cls.query()
3863 3871 query = query.filter(cls.external_id == external_id)
3864 3872 query = query.filter(cls.provider_name == provider_name)
3865 3873 if local_user_id:
3866 3874 query = query.filter(cls.local_user_id == local_user_id)
3867 3875 return query.first()
3868 3876
3869 3877 @classmethod
3870 3878 def user_by_external_id_and_provider(cls, external_id, provider_name):
3871 3879 """
3872 3880 Returns User instance based on search params
3873 3881
3874 3882 :param external_id:
3875 3883 :param provider_name:
3876 3884 :return: User
3877 3885 """
3878 3886 query = User.query()
3879 3887 query = query.filter(cls.external_id == external_id)
3880 3888 query = query.filter(cls.provider_name == provider_name)
3881 3889 query = query.filter(User.user_id == cls.local_user_id)
3882 3890 return query.first()
3883 3891
3884 3892 @classmethod
3885 3893 def by_local_user_id(cls, local_user_id):
3886 3894 """
3887 3895 Returns all tokens for user
3888 3896
3889 3897 :param local_user_id:
3890 3898 :return: ExternalIdentity
3891 3899 """
3892 3900 query = cls.query()
3893 3901 query = query.filter(cls.local_user_id == local_user_id)
3894 3902 return query
3895 3903
3896 3904
3897 3905 class Integration(Base, BaseModel):
3898 3906 __tablename__ = 'integrations'
3899 3907 __table_args__ = (
3900 3908 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3901 3909 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3902 3910 )
3903 3911
3904 3912 integration_id = Column('integration_id', Integer(), primary_key=True)
3905 3913 integration_type = Column('integration_type', String(255))
3906 3914 enabled = Column('enabled', Boolean(), nullable=False)
3907 3915 name = Column('name', String(255), nullable=False)
3908 3916 child_repos_only = Column('child_repos_only', Boolean(), nullable=False,
3909 3917 default=False)
3910 3918
3911 3919 settings = Column(
3912 3920 'settings_json', MutationObj.as_mutable(
3913 3921 JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
3914 3922 repo_id = Column(
3915 3923 'repo_id', Integer(), ForeignKey('repositories.repo_id'),
3916 3924 nullable=True, unique=None, default=None)
3917 3925 repo = relationship('Repository', lazy='joined')
3918 3926
3919 3927 repo_group_id = Column(
3920 3928 'repo_group_id', Integer(), ForeignKey('groups.group_id'),
3921 3929 nullable=True, unique=None, default=None)
3922 3930 repo_group = relationship('RepoGroup', lazy='joined')
3923 3931
3924 3932 @property
3925 3933 def scope(self):
3926 3934 if self.repo:
3927 3935 return repr(self.repo)
3928 3936 if self.repo_group:
3929 3937 if self.child_repos_only:
3930 3938 return repr(self.repo_group) + ' (child repos only)'
3931 3939 else:
3932 3940 return repr(self.repo_group) + ' (recursive)'
3933 3941 if self.child_repos_only:
3934 3942 return 'root_repos'
3935 3943 return 'global'
3936 3944
3937 3945 def __repr__(self):
3938 3946 return '<Integration(%r, %r)>' % (self.integration_type, self.scope)
3939 3947
3940 3948
3941 3949 class RepoReviewRuleUser(Base, BaseModel):
3942 3950 __tablename__ = 'repo_review_rules_users'
3943 3951 __table_args__ = (
3944 3952 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3945 3953 'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
3946 3954 )
3947 3955 repo_review_rule_user_id = Column('repo_review_rule_user_id', Integer(), primary_key=True)
3948 3956 repo_review_rule_id = Column("repo_review_rule_id", Integer(), ForeignKey('repo_review_rules.repo_review_rule_id'))
3949 3957 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False)
3950 3958 mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
3951 3959 user = relationship('User')
3952 3960
3953 3961 def rule_data(self):
3954 3962 return {
3955 3963 'mandatory': self.mandatory
3956 3964 }
3957 3965
3958 3966
3959 3967 class RepoReviewRuleUserGroup(Base, BaseModel):
3960 3968 __tablename__ = 'repo_review_rules_users_groups'
3961 3969 __table_args__ = (
3962 3970 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3963 3971 'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
3964 3972 )
3965 3973 repo_review_rule_users_group_id = Column('repo_review_rule_users_group_id', Integer(), primary_key=True)
3966 3974 repo_review_rule_id = Column("repo_review_rule_id", Integer(), ForeignKey('repo_review_rules.repo_review_rule_id'))
3967 3975 users_group_id = Column("users_group_id", Integer(),ForeignKey('users_groups.users_group_id'), nullable=False)
3968 3976 mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
3969 3977 users_group = relationship('UserGroup')
3970 3978
3971 3979 def rule_data(self):
3972 3980 return {
3973 3981 'mandatory': self.mandatory
3974 3982 }
3975 3983
3976 3984
3977 3985 class RepoReviewRule(Base, BaseModel):
3978 3986 __tablename__ = 'repo_review_rules'
3979 3987 __table_args__ = (
3980 3988 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3981 3989 'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
3982 3990 )
3983 3991
3984 3992 repo_review_rule_id = Column(
3985 3993 'repo_review_rule_id', Integer(), primary_key=True)
3986 3994 repo_id = Column(
3987 3995 "repo_id", Integer(), ForeignKey('repositories.repo_id'))
3988 3996 repo = relationship('Repository', backref='review_rules')
3989 3997
3990 3998 _branch_pattern = Column("branch_pattern", UnicodeText().with_variant(UnicodeText(255), 'mysql'), default=u'*') # glob
3991 3999 _file_pattern = Column("file_pattern", UnicodeText().with_variant(UnicodeText(255), 'mysql'), default=u'*') # glob
3992 4000
3993 4001 use_authors_for_review = Column("use_authors_for_review", Boolean(), nullable=False, default=False)
3994 4002 forbid_author_to_review = Column("forbid_author_to_review", Boolean(), nullable=False, default=False)
3995 4003 forbid_commit_author_to_review = Column("forbid_commit_author_to_review", Boolean(), nullable=False, default=False)
3996 4004 forbid_adding_reviewers = Column("forbid_adding_reviewers", Boolean(), nullable=False, default=False)
3997 4005
3998 4006 rule_users = relationship('RepoReviewRuleUser')
3999 4007 rule_user_groups = relationship('RepoReviewRuleUserGroup')
4000 4008
4001 4009 @hybrid_property
4002 4010 def branch_pattern(self):
4003 4011 return self._branch_pattern or '*'
4004 4012
4005 4013 def _validate_glob(self, value):
4006 4014 re.compile('^' + glob2re(value) + '$')
4007 4015
4008 4016 @branch_pattern.setter
4009 4017 def branch_pattern(self, value):
4010 4018 self._validate_glob(value)
4011 4019 self._branch_pattern = value or '*'
4012 4020
4013 4021 @hybrid_property
4014 4022 def file_pattern(self):
4015 4023 return self._file_pattern or '*'
4016 4024
4017 4025 @file_pattern.setter
4018 4026 def file_pattern(self, value):
4019 4027 self._validate_glob(value)
4020 4028 self._file_pattern = value or '*'
4021 4029
4022 4030 def matches(self, branch, files_changed):
4023 4031 """
4024 4032 Check if this review rule matches a branch/files in a pull request
4025 4033
4026 4034 :param branch: branch name for the commit
4027 4035 :param files_changed: list of file paths changed in the pull request
4028 4036 """
4029 4037
4030 4038 branch = branch or ''
4031 4039 files_changed = files_changed or []
4032 4040
4033 4041 branch_matches = True
4034 4042 if branch:
4035 4043 branch_regex = re.compile('^' + glob2re(self.branch_pattern) + '$')
4036 4044 branch_matches = bool(branch_regex.search(branch))
4037 4045
4038 4046 files_matches = True
4039 4047 if self.file_pattern != '*':
4040 4048 files_matches = False
4041 4049 file_regex = re.compile(glob2re(self.file_pattern))
4042 4050 for filename in files_changed:
4043 4051 if file_regex.search(filename):
4044 4052 files_matches = True
4045 4053 break
4046 4054
4047 4055 return branch_matches and files_matches
4048 4056
4049 4057 @property
4050 4058 def review_users(self):
4051 4059 """ Returns the users which this rule applies to """
4052 4060
4053 4061 users = collections.OrderedDict()
4054 4062
4055 4063 for rule_user in self.rule_users:
4056 4064 if rule_user.user.active:
4057 4065 if rule_user.user not in users:
4058 4066 users[rule_user.user.username] = {
4059 4067 'user': rule_user.user,
4060 4068 'source': 'user',
4061 4069 'source_data': {},
4062 4070 'data': rule_user.rule_data()
4063 4071 }
4064 4072
4065 4073 for rule_user_group in self.rule_user_groups:
4066 4074 source_data = {
4067 4075 'name': rule_user_group.users_group.users_group_name,
4068 4076 'members': len(rule_user_group.users_group.members)
4069 4077 }
4070 4078 for member in rule_user_group.users_group.members:
4071 4079 if member.user.active:
4072 4080 users[member.user.username] = {
4073 4081 'user': member.user,
4074 4082 'source': 'user_group',
4075 4083 'source_data': source_data,
4076 4084 'data': rule_user_group.rule_data()
4077 4085 }
4078 4086
4079 4087 return users
4080 4088
4081 4089 def __repr__(self):
4082 4090 return '<RepoReviewerRule(id=%r, repo=%r)>' % (
4083 4091 self.repo_review_rule_id, self.repo)
4084 4092
4085 4093
4086 4094 class DbMigrateVersion(Base, BaseModel):
4087 4095 __tablename__ = 'db_migrate_version'
4088 4096 __table_args__ = (
4089 4097 {'extend_existing': True, 'mysql_engine': 'InnoDB',
4090 4098 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
4091 4099 )
4092 4100 repository_id = Column('repository_id', String(250), primary_key=True)
4093 4101 repository_path = Column('repository_path', Text)
4094 4102 version = Column('version', Integer)
4095 4103
4096 4104
4097 4105 class DbSession(Base, BaseModel):
4098 4106 __tablename__ = 'db_session'
4099 4107 __table_args__ = (
4100 4108 {'extend_existing': True, 'mysql_engine': 'InnoDB',
4101 4109 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
4102 4110 )
4103 4111
4104 4112 def __repr__(self):
4105 4113 return '<DB:DbSession({})>'.format(self.id)
4106 4114
4107 4115 id = Column('id', Integer())
4108 4116 namespace = Column('namespace', String(255), primary_key=True)
4109 4117 accessed = Column('accessed', DateTime, nullable=False)
4110 4118 created = Column('created', DateTime, nullable=False)
4111 4119 data = Column('data', PickleType, nullable=False)
@@ -1,1551 +1,1551 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from pyramid.threadlocal import get_current_request
35 35 from sqlalchemy import or_
36 36
37 37 from rhodecode import events
38 38 from rhodecode.lib import helpers as h, hooks_utils, diffs
39 39 from rhodecode.lib import audit_logger
40 40 from rhodecode.lib.compat import OrderedDict
41 41 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
42 42 from rhodecode.lib.markup_renderer import (
43 43 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
44 44 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
45 45 from rhodecode.lib.vcs.backends.base import (
46 46 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
47 47 from rhodecode.lib.vcs.conf import settings as vcs_settings
48 48 from rhodecode.lib.vcs.exceptions import (
49 49 CommitDoesNotExistError, EmptyRepositoryError)
50 50 from rhodecode.model import BaseModel
51 51 from rhodecode.model.changeset_status import ChangesetStatusModel
52 52 from rhodecode.model.comment import CommentsModel
53 53 from rhodecode.model.db import (
54 54 PullRequest, PullRequestReviewers, ChangesetStatus,
55 55 PullRequestVersion, ChangesetComment, Repository)
56 56 from rhodecode.model.meta import Session
57 57 from rhodecode.model.notification import NotificationModel, \
58 58 EmailNotificationModel
59 59 from rhodecode.model.scm import ScmModel
60 60 from rhodecode.model.settings import VcsSettingsModel
61 61
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65
66 66 # Data structure to hold the response data when updating commits during a pull
67 67 # request update.
68 68 UpdateResponse = namedtuple('UpdateResponse', [
69 69 'executed', 'reason', 'new', 'old', 'changes',
70 70 'source_changed', 'target_changed'])
71 71
72 72
73 73 class PullRequestModel(BaseModel):
74 74
75 75 cls = PullRequest
76 76
77 77 DIFF_CONTEXT = 3
78 78
79 79 MERGE_STATUS_MESSAGES = {
80 80 MergeFailureReason.NONE: lazy_ugettext(
81 81 'This pull request can be automatically merged.'),
82 82 MergeFailureReason.UNKNOWN: lazy_ugettext(
83 83 'This pull request cannot be merged because of an unhandled'
84 84 ' exception.'),
85 85 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
86 86 'This pull request cannot be merged because of merge conflicts.'),
87 87 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
88 88 'This pull request could not be merged because push to target'
89 89 ' failed.'),
90 90 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
91 91 'This pull request cannot be merged because the target is not a'
92 92 ' head.'),
93 93 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
94 94 'This pull request cannot be merged because the source contains'
95 95 ' more branches than the target.'),
96 96 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
97 97 'This pull request cannot be merged because the target has'
98 98 ' multiple heads.'),
99 99 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
100 100 'This pull request cannot be merged because the target repository'
101 101 ' is locked.'),
102 102 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
103 103 'This pull request cannot be merged because the target or the '
104 104 'source reference is missing.'),
105 105 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
106 106 'This pull request cannot be merged because the target '
107 107 'reference is missing.'),
108 108 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
109 109 'This pull request cannot be merged because the source '
110 110 'reference is missing.'),
111 111 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
112 112 'This pull request cannot be merged because of conflicts related '
113 113 'to sub repositories.'),
114 114 }
115 115
116 116 UPDATE_STATUS_MESSAGES = {
117 117 UpdateFailureReason.NONE: lazy_ugettext(
118 118 'Pull request update successful.'),
119 119 UpdateFailureReason.UNKNOWN: lazy_ugettext(
120 120 'Pull request update failed because of an unknown error.'),
121 121 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
122 122 'No update needed because the source and target have not changed.'),
123 123 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
124 124 'Pull request cannot be updated because the reference type is '
125 125 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
126 126 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
127 127 'This pull request cannot be updated because the target '
128 128 'reference is missing.'),
129 129 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
130 130 'This pull request cannot be updated because the source '
131 131 'reference is missing.'),
132 132 }
133 133
134 134 def __get_pull_request(self, pull_request):
135 135 return self._get_instance((
136 136 PullRequest, PullRequestVersion), pull_request)
137 137
138 138 def _check_perms(self, perms, pull_request, user, api=False):
139 139 if not api:
140 140 return h.HasRepoPermissionAny(*perms)(
141 141 user=user, repo_name=pull_request.target_repo.repo_name)
142 142 else:
143 143 return h.HasRepoPermissionAnyApi(*perms)(
144 144 user=user, repo_name=pull_request.target_repo.repo_name)
145 145
146 146 def check_user_read(self, pull_request, user, api=False):
147 147 _perms = ('repository.admin', 'repository.write', 'repository.read',)
148 148 return self._check_perms(_perms, pull_request, user, api)
149 149
150 150 def check_user_merge(self, pull_request, user, api=False):
151 151 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
152 152 return self._check_perms(_perms, pull_request, user, api)
153 153
154 154 def check_user_update(self, pull_request, user, api=False):
155 155 owner = user.user_id == pull_request.user_id
156 156 return self.check_user_merge(pull_request, user, api) or owner
157 157
158 158 def check_user_delete(self, pull_request, user):
159 159 owner = user.user_id == pull_request.user_id
160 160 _perms = ('repository.admin',)
161 161 return self._check_perms(_perms, pull_request, user) or owner
162 162
163 163 def check_user_change_status(self, pull_request, user, api=False):
164 164 reviewer = user.user_id in [x.user_id for x in
165 165 pull_request.reviewers]
166 166 return self.check_user_update(pull_request, user, api) or reviewer
167 167
168 168 def get(self, pull_request):
169 169 return self.__get_pull_request(pull_request)
170 170
171 171 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
172 172 opened_by=None, order_by=None,
173 173 order_dir='desc'):
174 174 repo = None
175 175 if repo_name:
176 176 repo = self._get_repo(repo_name)
177 177
178 178 q = PullRequest.query()
179 179
180 180 # source or target
181 181 if repo and source:
182 182 q = q.filter(PullRequest.source_repo == repo)
183 183 elif repo:
184 184 q = q.filter(PullRequest.target_repo == repo)
185 185
186 186 # closed,opened
187 187 if statuses:
188 188 q = q.filter(PullRequest.status.in_(statuses))
189 189
190 190 # opened by filter
191 191 if opened_by:
192 192 q = q.filter(PullRequest.user_id.in_(opened_by))
193 193
194 194 if order_by:
195 195 order_map = {
196 196 'name_raw': PullRequest.pull_request_id,
197 197 'title': PullRequest.title,
198 198 'updated_on_raw': PullRequest.updated_on,
199 199 'target_repo': PullRequest.target_repo_id
200 200 }
201 201 if order_dir == 'asc':
202 202 q = q.order_by(order_map[order_by].asc())
203 203 else:
204 204 q = q.order_by(order_map[order_by].desc())
205 205
206 206 return q
207 207
208 208 def count_all(self, repo_name, source=False, statuses=None,
209 209 opened_by=None):
210 210 """
211 211 Count the number of pull requests for a specific repository.
212 212
213 213 :param repo_name: target or source repo
214 214 :param source: boolean flag to specify if repo_name refers to source
215 215 :param statuses: list of pull request statuses
216 216 :param opened_by: author user of the pull request
217 217 :returns: int number of pull requests
218 218 """
219 219 q = self._prepare_get_all_query(
220 220 repo_name, source=source, statuses=statuses, opened_by=opened_by)
221 221
222 222 return q.count()
223 223
224 224 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
225 225 offset=0, length=None, order_by=None, order_dir='desc'):
226 226 """
227 227 Get all pull requests for a specific repository.
228 228
229 229 :param repo_name: target or source repo
230 230 :param source: boolean flag to specify if repo_name refers to source
231 231 :param statuses: list of pull request statuses
232 232 :param opened_by: author user of the pull request
233 233 :param offset: pagination offset
234 234 :param length: length of returned list
235 235 :param order_by: order of the returned list
236 236 :param order_dir: 'asc' or 'desc' ordering direction
237 237 :returns: list of pull requests
238 238 """
239 239 q = self._prepare_get_all_query(
240 240 repo_name, source=source, statuses=statuses, opened_by=opened_by,
241 241 order_by=order_by, order_dir=order_dir)
242 242
243 243 if length:
244 244 pull_requests = q.limit(length).offset(offset).all()
245 245 else:
246 246 pull_requests = q.all()
247 247
248 248 return pull_requests
249 249
250 250 def count_awaiting_review(self, repo_name, source=False, statuses=None,
251 251 opened_by=None):
252 252 """
253 253 Count the number of pull requests for a specific repository that are
254 254 awaiting review.
255 255
256 256 :param repo_name: target or source repo
257 257 :param source: boolean flag to specify if repo_name refers to source
258 258 :param statuses: list of pull request statuses
259 259 :param opened_by: author user of the pull request
260 260 :returns: int number of pull requests
261 261 """
262 262 pull_requests = self.get_awaiting_review(
263 263 repo_name, source=source, statuses=statuses, opened_by=opened_by)
264 264
265 265 return len(pull_requests)
266 266
267 267 def get_awaiting_review(self, repo_name, source=False, statuses=None,
268 268 opened_by=None, offset=0, length=None,
269 269 order_by=None, order_dir='desc'):
270 270 """
271 271 Get all pull requests for a specific repository that are awaiting
272 272 review.
273 273
274 274 :param repo_name: target or source repo
275 275 :param source: boolean flag to specify if repo_name refers to source
276 276 :param statuses: list of pull request statuses
277 277 :param opened_by: author user of the pull request
278 278 :param offset: pagination offset
279 279 :param length: length of returned list
280 280 :param order_by: order of the returned list
281 281 :param order_dir: 'asc' or 'desc' ordering direction
282 282 :returns: list of pull requests
283 283 """
284 284 pull_requests = self.get_all(
285 285 repo_name, source=source, statuses=statuses, opened_by=opened_by,
286 286 order_by=order_by, order_dir=order_dir)
287 287
288 288 _filtered_pull_requests = []
289 289 for pr in pull_requests:
290 290 status = pr.calculated_review_status()
291 291 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
292 292 ChangesetStatus.STATUS_UNDER_REVIEW]:
293 293 _filtered_pull_requests.append(pr)
294 294 if length:
295 295 return _filtered_pull_requests[offset:offset+length]
296 296 else:
297 297 return _filtered_pull_requests
298 298
299 299 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
300 300 opened_by=None, user_id=None):
301 301 """
302 302 Count the number of pull requests for a specific repository that are
303 303 awaiting review from a specific user.
304 304
305 305 :param repo_name: target or source repo
306 306 :param source: boolean flag to specify if repo_name refers to source
307 307 :param statuses: list of pull request statuses
308 308 :param opened_by: author user of the pull request
309 309 :param user_id: reviewer user of the pull request
310 310 :returns: int number of pull requests
311 311 """
312 312 pull_requests = self.get_awaiting_my_review(
313 313 repo_name, source=source, statuses=statuses, opened_by=opened_by,
314 314 user_id=user_id)
315 315
316 316 return len(pull_requests)
317 317
318 318 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
319 319 opened_by=None, user_id=None, offset=0,
320 320 length=None, order_by=None, order_dir='desc'):
321 321 """
322 322 Get all pull requests for a specific repository that are awaiting
323 323 review from a specific user.
324 324
325 325 :param repo_name: target or source repo
326 326 :param source: boolean flag to specify if repo_name refers to source
327 327 :param statuses: list of pull request statuses
328 328 :param opened_by: author user of the pull request
329 329 :param user_id: reviewer user of the pull request
330 330 :param offset: pagination offset
331 331 :param length: length of returned list
332 332 :param order_by: order of the returned list
333 333 :param order_dir: 'asc' or 'desc' ordering direction
334 334 :returns: list of pull requests
335 335 """
336 336 pull_requests = self.get_all(
337 337 repo_name, source=source, statuses=statuses, opened_by=opened_by,
338 338 order_by=order_by, order_dir=order_dir)
339 339
340 340 _my = PullRequestModel().get_not_reviewed(user_id)
341 341 my_participation = []
342 342 for pr in pull_requests:
343 343 if pr in _my:
344 344 my_participation.append(pr)
345 345 _filtered_pull_requests = my_participation
346 346 if length:
347 347 return _filtered_pull_requests[offset:offset+length]
348 348 else:
349 349 return _filtered_pull_requests
350 350
351 351 def get_not_reviewed(self, user_id):
352 352 return [
353 353 x.pull_request for x in PullRequestReviewers.query().filter(
354 354 PullRequestReviewers.user_id == user_id).all()
355 355 ]
356 356
357 357 def _prepare_participating_query(self, user_id=None, statuses=None,
358 358 order_by=None, order_dir='desc'):
359 359 q = PullRequest.query()
360 360 if user_id:
361 361 reviewers_subquery = Session().query(
362 362 PullRequestReviewers.pull_request_id).filter(
363 363 PullRequestReviewers.user_id == user_id).subquery()
364 364 user_filter= or_(
365 365 PullRequest.user_id == user_id,
366 366 PullRequest.pull_request_id.in_(reviewers_subquery)
367 367 )
368 368 q = PullRequest.query().filter(user_filter)
369 369
370 370 # closed,opened
371 371 if statuses:
372 372 q = q.filter(PullRequest.status.in_(statuses))
373 373
374 374 if order_by:
375 375 order_map = {
376 376 'name_raw': PullRequest.pull_request_id,
377 377 'title': PullRequest.title,
378 378 'updated_on_raw': PullRequest.updated_on,
379 379 'target_repo': PullRequest.target_repo_id
380 380 }
381 381 if order_dir == 'asc':
382 382 q = q.order_by(order_map[order_by].asc())
383 383 else:
384 384 q = q.order_by(order_map[order_by].desc())
385 385
386 386 return q
387 387
388 388 def count_im_participating_in(self, user_id=None, statuses=None):
389 389 q = self._prepare_participating_query(user_id, statuses=statuses)
390 390 return q.count()
391 391
392 392 def get_im_participating_in(
393 393 self, user_id=None, statuses=None, offset=0,
394 394 length=None, order_by=None, order_dir='desc'):
395 395 """
396 396 Get all Pull requests that i'm participating in, or i have opened
397 397 """
398 398
399 399 q = self._prepare_participating_query(
400 400 user_id, statuses=statuses, order_by=order_by,
401 401 order_dir=order_dir)
402 402
403 403 if length:
404 404 pull_requests = q.limit(length).offset(offset).all()
405 405 else:
406 406 pull_requests = q.all()
407 407
408 408 return pull_requests
409 409
410 410 def get_versions(self, pull_request):
411 411 """
412 412 returns version of pull request sorted by ID descending
413 413 """
414 414 return PullRequestVersion.query()\
415 415 .filter(PullRequestVersion.pull_request == pull_request)\
416 416 .order_by(PullRequestVersion.pull_request_version_id.asc())\
417 417 .all()
418 418
419 419 def create(self, created_by, source_repo, source_ref, target_repo,
420 420 target_ref, revisions, reviewers, title, description=None,
421 421 reviewer_data=None):
422 422
423 423 created_by_user = self._get_user(created_by)
424 424 source_repo = self._get_repo(source_repo)
425 425 target_repo = self._get_repo(target_repo)
426 426
427 427 pull_request = PullRequest()
428 428 pull_request.source_repo = source_repo
429 429 pull_request.source_ref = source_ref
430 430 pull_request.target_repo = target_repo
431 431 pull_request.target_ref = target_ref
432 432 pull_request.revisions = revisions
433 433 pull_request.title = title
434 434 pull_request.description = description
435 435 pull_request.author = created_by_user
436 436 pull_request.reviewer_data = reviewer_data
437 437
438 438 Session().add(pull_request)
439 439 Session().flush()
440 440
441 441 reviewer_ids = set()
442 442 # members / reviewers
443 443 for reviewer_object in reviewers:
444 444 user_id, reasons, mandatory = reviewer_object
445 445 user = self._get_user(user_id)
446 446
447 447 # skip duplicates
448 448 if user.user_id in reviewer_ids:
449 449 continue
450 450
451 451 reviewer_ids.add(user.user_id)
452 452
453 453 reviewer = PullRequestReviewers()
454 454 reviewer.user = user
455 455 reviewer.pull_request = pull_request
456 456 reviewer.reasons = reasons
457 457 reviewer.mandatory = mandatory
458 458 Session().add(reviewer)
459 459
460 460 # Set approval status to "Under Review" for all commits which are
461 461 # part of this pull request.
462 462 ChangesetStatusModel().set_status(
463 463 repo=target_repo,
464 464 status=ChangesetStatus.STATUS_UNDER_REVIEW,
465 465 user=created_by_user,
466 466 pull_request=pull_request
467 467 )
468 468
469 469 self.notify_reviewers(pull_request, reviewer_ids)
470 470 self._trigger_pull_request_hook(
471 471 pull_request, created_by_user, 'create')
472 472
473 473 creation_data = pull_request.get_api_data(with_merge_state=False)
474 474 self._log_audit_action(
475 475 'repo.pull_request.create', {'data': creation_data},
476 476 created_by_user, pull_request)
477 477
478 478 return pull_request
479 479
480 480 def _trigger_pull_request_hook(self, pull_request, user, action):
481 481 pull_request = self.__get_pull_request(pull_request)
482 482 target_scm = pull_request.target_repo.scm_instance()
483 483 if action == 'create':
484 484 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
485 485 elif action == 'merge':
486 486 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
487 487 elif action == 'close':
488 488 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
489 489 elif action == 'review_status_change':
490 490 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
491 491 elif action == 'update':
492 492 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
493 493 else:
494 494 return
495 495
496 496 trigger_hook(
497 497 username=user.username,
498 498 repo_name=pull_request.target_repo.repo_name,
499 499 repo_alias=target_scm.alias,
500 500 pull_request=pull_request)
501 501
502 502 def _get_commit_ids(self, pull_request):
503 503 """
504 504 Return the commit ids of the merged pull request.
505 505
506 506 This method is not dealing correctly yet with the lack of autoupdates
507 507 nor with the implicit target updates.
508 508 For example: if a commit in the source repo is already in the target it
509 509 will be reported anyways.
510 510 """
511 511 merge_rev = pull_request.merge_rev
512 512 if merge_rev is None:
513 513 raise ValueError('This pull request was not merged yet')
514 514
515 515 commit_ids = list(pull_request.revisions)
516 516 if merge_rev not in commit_ids:
517 517 commit_ids.append(merge_rev)
518 518
519 519 return commit_ids
520 520
521 521 def merge(self, pull_request, user, extras):
522 522 log.debug("Merging pull request %s", pull_request.pull_request_id)
523 523 merge_state = self._merge_pull_request(pull_request, user, extras)
524 524 if merge_state.executed:
525 525 log.debug(
526 526 "Merge was successful, updating the pull request comments.")
527 527 self._comment_and_close_pr(pull_request, user, merge_state)
528 528
529 529 self._log_audit_action(
530 530 'repo.pull_request.merge',
531 531 {'merge_state': merge_state.__dict__},
532 532 user, pull_request)
533 533
534 534 else:
535 535 log.warn("Merge failed, not updating the pull request.")
536 536 return merge_state
537 537
538 538 def _merge_pull_request(self, pull_request, user, extras):
539 539 target_vcs = pull_request.target_repo.scm_instance()
540 540 source_vcs = pull_request.source_repo.scm_instance()
541 541 target_ref = self._refresh_reference(
542 542 pull_request.target_ref_parts, target_vcs)
543 543
544 544 message = _(
545 545 'Merge pull request #%(pr_id)s from '
546 546 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
547 547 'pr_id': pull_request.pull_request_id,
548 548 'source_repo': source_vcs.name,
549 549 'source_ref_name': pull_request.source_ref_parts.name,
550 550 'pr_title': pull_request.title
551 551 }
552 552
553 553 workspace_id = self._workspace_id(pull_request)
554 554 use_rebase = self._use_rebase_for_merging(pull_request)
555 555
556 556 callback_daemon, extras = prepare_callback_daemon(
557 557 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
558 558 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
559 559
560 560 with callback_daemon:
561 561 # TODO: johbo: Implement a clean way to run a config_override
562 562 # for a single call.
563 563 target_vcs.config.set(
564 564 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
565 565 merge_state = target_vcs.merge(
566 566 target_ref, source_vcs, pull_request.source_ref_parts,
567 567 workspace_id, user_name=user.username,
568 568 user_email=user.email, message=message, use_rebase=use_rebase)
569 569 return merge_state
570 570
571 571 def _comment_and_close_pr(self, pull_request, user, merge_state):
572 572 pull_request.merge_rev = merge_state.merge_ref.commit_id
573 573 pull_request.updated_on = datetime.datetime.now()
574 574
575 575 CommentsModel().create(
576 576 text=unicode(_('Pull request merged and closed')),
577 577 repo=pull_request.target_repo.repo_id,
578 578 user=user.user_id,
579 579 pull_request=pull_request.pull_request_id,
580 580 f_path=None,
581 581 line_no=None,
582 582 closing_pr=True
583 583 )
584 584
585 585 Session().add(pull_request)
586 586 Session().flush()
587 587 # TODO: paris: replace invalidation with less radical solution
588 588 ScmModel().mark_for_invalidation(
589 589 pull_request.target_repo.repo_name)
590 590 self._trigger_pull_request_hook(pull_request, user, 'merge')
591 591
592 592 def has_valid_update_type(self, pull_request):
593 593 source_ref_type = pull_request.source_ref_parts.type
594 594 return source_ref_type in ['book', 'branch', 'tag']
595 595
596 596 def update_commits(self, pull_request):
597 597 """
598 598 Get the updated list of commits for the pull request
599 599 and return the new pull request version and the list
600 600 of commits processed by this update action
601 601 """
602 602 pull_request = self.__get_pull_request(pull_request)
603 603 source_ref_type = pull_request.source_ref_parts.type
604 604 source_ref_name = pull_request.source_ref_parts.name
605 605 source_ref_id = pull_request.source_ref_parts.commit_id
606 606
607 607 target_ref_type = pull_request.target_ref_parts.type
608 608 target_ref_name = pull_request.target_ref_parts.name
609 609 target_ref_id = pull_request.target_ref_parts.commit_id
610 610
611 611 if not self.has_valid_update_type(pull_request):
612 612 log.debug(
613 613 "Skipping update of pull request %s due to ref type: %s",
614 614 pull_request, source_ref_type)
615 615 return UpdateResponse(
616 616 executed=False,
617 617 reason=UpdateFailureReason.WRONG_REF_TYPE,
618 618 old=pull_request, new=None, changes=None,
619 619 source_changed=False, target_changed=False)
620 620
621 621 # source repo
622 622 source_repo = pull_request.source_repo.scm_instance()
623 623 try:
624 624 source_commit = source_repo.get_commit(commit_id=source_ref_name)
625 625 except CommitDoesNotExistError:
626 626 return UpdateResponse(
627 627 executed=False,
628 628 reason=UpdateFailureReason.MISSING_SOURCE_REF,
629 629 old=pull_request, new=None, changes=None,
630 630 source_changed=False, target_changed=False)
631 631
632 632 source_changed = source_ref_id != source_commit.raw_id
633 633
634 634 # target repo
635 635 target_repo = pull_request.target_repo.scm_instance()
636 636 try:
637 637 target_commit = target_repo.get_commit(commit_id=target_ref_name)
638 638 except CommitDoesNotExistError:
639 639 return UpdateResponse(
640 640 executed=False,
641 641 reason=UpdateFailureReason.MISSING_TARGET_REF,
642 642 old=pull_request, new=None, changes=None,
643 643 source_changed=False, target_changed=False)
644 644 target_changed = target_ref_id != target_commit.raw_id
645 645
646 646 if not (source_changed or target_changed):
647 647 log.debug("Nothing changed in pull request %s", pull_request)
648 648 return UpdateResponse(
649 649 executed=False,
650 650 reason=UpdateFailureReason.NO_CHANGE,
651 651 old=pull_request, new=None, changes=None,
652 652 source_changed=target_changed, target_changed=source_changed)
653 653
654 654 change_in_found = 'target repo' if target_changed else 'source repo'
655 655 log.debug('Updating pull request because of change in %s detected',
656 656 change_in_found)
657 657
658 658 # Finally there is a need for an update, in case of source change
659 659 # we create a new version, else just an update
660 660 if source_changed:
661 661 pull_request_version = self._create_version_from_snapshot(pull_request)
662 662 self._link_comments_to_version(pull_request_version)
663 663 else:
664 664 try:
665 665 ver = pull_request.versions[-1]
666 666 except IndexError:
667 667 ver = None
668 668
669 669 pull_request.pull_request_version_id = \
670 670 ver.pull_request_version_id if ver else None
671 671 pull_request_version = pull_request
672 672
673 673 try:
674 674 if target_ref_type in ('tag', 'branch', 'book'):
675 675 target_commit = target_repo.get_commit(target_ref_name)
676 676 else:
677 677 target_commit = target_repo.get_commit(target_ref_id)
678 678 except CommitDoesNotExistError:
679 679 return UpdateResponse(
680 680 executed=False,
681 681 reason=UpdateFailureReason.MISSING_TARGET_REF,
682 682 old=pull_request, new=None, changes=None,
683 683 source_changed=source_changed, target_changed=target_changed)
684 684
685 685 # re-compute commit ids
686 686 old_commit_ids = pull_request.revisions
687 687 pre_load = ["author", "branch", "date", "message"]
688 688 commit_ranges = target_repo.compare(
689 689 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
690 690 pre_load=pre_load)
691 691
692 692 ancestor = target_repo.get_common_ancestor(
693 693 target_commit.raw_id, source_commit.raw_id, source_repo)
694 694
695 695 pull_request.source_ref = '%s:%s:%s' % (
696 696 source_ref_type, source_ref_name, source_commit.raw_id)
697 697 pull_request.target_ref = '%s:%s:%s' % (
698 698 target_ref_type, target_ref_name, ancestor)
699 699
700 700 pull_request.revisions = [
701 701 commit.raw_id for commit in reversed(commit_ranges)]
702 702 pull_request.updated_on = datetime.datetime.now()
703 703 Session().add(pull_request)
704 704 new_commit_ids = pull_request.revisions
705 705
706 706 old_diff_data, new_diff_data = self._generate_update_diffs(
707 707 pull_request, pull_request_version)
708 708
709 709 # calculate commit and file changes
710 710 changes = self._calculate_commit_id_changes(
711 711 old_commit_ids, new_commit_ids)
712 712 file_changes = self._calculate_file_changes(
713 713 old_diff_data, new_diff_data)
714 714
715 715 # set comments as outdated if DIFFS changed
716 716 CommentsModel().outdate_comments(
717 717 pull_request, old_diff_data=old_diff_data,
718 718 new_diff_data=new_diff_data)
719 719
720 720 commit_changes = (changes.added or changes.removed)
721 721 file_node_changes = (
722 722 file_changes.added or file_changes.modified or file_changes.removed)
723 723 pr_has_changes = commit_changes or file_node_changes
724 724
725 725 # Add an automatic comment to the pull request, in case
726 726 # anything has changed
727 727 if pr_has_changes:
728 728 update_comment = CommentsModel().create(
729 729 text=self._render_update_message(changes, file_changes),
730 730 repo=pull_request.target_repo,
731 731 user=pull_request.author,
732 732 pull_request=pull_request,
733 733 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
734 734
735 735 # Update status to "Under Review" for added commits
736 736 for commit_id in changes.added:
737 737 ChangesetStatusModel().set_status(
738 738 repo=pull_request.source_repo,
739 739 status=ChangesetStatus.STATUS_UNDER_REVIEW,
740 740 comment=update_comment,
741 741 user=pull_request.author,
742 742 pull_request=pull_request,
743 743 revision=commit_id)
744 744
745 745 log.debug(
746 746 'Updated pull request %s, added_ids: %s, common_ids: %s, '
747 747 'removed_ids: %s', pull_request.pull_request_id,
748 748 changes.added, changes.common, changes.removed)
749 749 log.debug(
750 750 'Updated pull request with the following file changes: %s',
751 751 file_changes)
752 752
753 753 log.info(
754 754 "Updated pull request %s from commit %s to commit %s, "
755 755 "stored new version %s of this pull request.",
756 756 pull_request.pull_request_id, source_ref_id,
757 757 pull_request.source_ref_parts.commit_id,
758 758 pull_request_version.pull_request_version_id)
759 759 Session().commit()
760 760 self._trigger_pull_request_hook(
761 761 pull_request, pull_request.author, 'update')
762 762
763 763 return UpdateResponse(
764 764 executed=True, reason=UpdateFailureReason.NONE,
765 765 old=pull_request, new=pull_request_version, changes=changes,
766 766 source_changed=source_changed, target_changed=target_changed)
767 767
768 768 def _create_version_from_snapshot(self, pull_request):
769 769 version = PullRequestVersion()
770 770 version.title = pull_request.title
771 771 version.description = pull_request.description
772 772 version.status = pull_request.status
773 773 version.created_on = datetime.datetime.now()
774 774 version.updated_on = pull_request.updated_on
775 775 version.user_id = pull_request.user_id
776 776 version.source_repo = pull_request.source_repo
777 777 version.source_ref = pull_request.source_ref
778 778 version.target_repo = pull_request.target_repo
779 779 version.target_ref = pull_request.target_ref
780 780
781 781 version._last_merge_source_rev = pull_request._last_merge_source_rev
782 782 version._last_merge_target_rev = pull_request._last_merge_target_rev
783 version._last_merge_status = pull_request._last_merge_status
783 version.last_merge_status = pull_request.last_merge_status
784 784 version.shadow_merge_ref = pull_request.shadow_merge_ref
785 785 version.merge_rev = pull_request.merge_rev
786 786 version.reviewer_data = pull_request.reviewer_data
787 787
788 788 version.revisions = pull_request.revisions
789 789 version.pull_request = pull_request
790 790 Session().add(version)
791 791 Session().flush()
792 792
793 793 return version
794 794
795 795 def _generate_update_diffs(self, pull_request, pull_request_version):
796 796
797 797 diff_context = (
798 798 self.DIFF_CONTEXT +
799 799 CommentsModel.needed_extra_diff_context())
800 800
801 801 source_repo = pull_request_version.source_repo
802 802 source_ref_id = pull_request_version.source_ref_parts.commit_id
803 803 target_ref_id = pull_request_version.target_ref_parts.commit_id
804 804 old_diff = self._get_diff_from_pr_or_version(
805 805 source_repo, source_ref_id, target_ref_id, context=diff_context)
806 806
807 807 source_repo = pull_request.source_repo
808 808 source_ref_id = pull_request.source_ref_parts.commit_id
809 809 target_ref_id = pull_request.target_ref_parts.commit_id
810 810
811 811 new_diff = self._get_diff_from_pr_or_version(
812 812 source_repo, source_ref_id, target_ref_id, context=diff_context)
813 813
814 814 old_diff_data = diffs.DiffProcessor(old_diff)
815 815 old_diff_data.prepare()
816 816 new_diff_data = diffs.DiffProcessor(new_diff)
817 817 new_diff_data.prepare()
818 818
819 819 return old_diff_data, new_diff_data
820 820
821 821 def _link_comments_to_version(self, pull_request_version):
822 822 """
823 823 Link all unlinked comments of this pull request to the given version.
824 824
825 825 :param pull_request_version: The `PullRequestVersion` to which
826 826 the comments shall be linked.
827 827
828 828 """
829 829 pull_request = pull_request_version.pull_request
830 830 comments = ChangesetComment.query()\
831 831 .filter(
832 832 # TODO: johbo: Should we query for the repo at all here?
833 833 # Pending decision on how comments of PRs are to be related
834 834 # to either the source repo, the target repo or no repo at all.
835 835 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
836 836 ChangesetComment.pull_request == pull_request,
837 837 ChangesetComment.pull_request_version == None)\
838 838 .order_by(ChangesetComment.comment_id.asc())
839 839
840 840 # TODO: johbo: Find out why this breaks if it is done in a bulk
841 841 # operation.
842 842 for comment in comments:
843 843 comment.pull_request_version_id = (
844 844 pull_request_version.pull_request_version_id)
845 845 Session().add(comment)
846 846
847 847 def _calculate_commit_id_changes(self, old_ids, new_ids):
848 848 added = [x for x in new_ids if x not in old_ids]
849 849 common = [x for x in new_ids if x in old_ids]
850 850 removed = [x for x in old_ids if x not in new_ids]
851 851 total = new_ids
852 852 return ChangeTuple(added, common, removed, total)
853 853
854 854 def _calculate_file_changes(self, old_diff_data, new_diff_data):
855 855
856 856 old_files = OrderedDict()
857 857 for diff_data in old_diff_data.parsed_diff:
858 858 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
859 859
860 860 added_files = []
861 861 modified_files = []
862 862 removed_files = []
863 863 for diff_data in new_diff_data.parsed_diff:
864 864 new_filename = diff_data['filename']
865 865 new_hash = md5_safe(diff_data['raw_diff'])
866 866
867 867 old_hash = old_files.get(new_filename)
868 868 if not old_hash:
869 869 # file is not present in old diff, means it's added
870 870 added_files.append(new_filename)
871 871 else:
872 872 if new_hash != old_hash:
873 873 modified_files.append(new_filename)
874 874 # now remove a file from old, since we have seen it already
875 875 del old_files[new_filename]
876 876
877 877 # removed files is when there are present in old, but not in NEW,
878 878 # since we remove old files that are present in new diff, left-overs
879 879 # if any should be the removed files
880 880 removed_files.extend(old_files.keys())
881 881
882 882 return FileChangeTuple(added_files, modified_files, removed_files)
883 883
884 884 def _render_update_message(self, changes, file_changes):
885 885 """
886 886 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
887 887 so it's always looking the same disregarding on which default
888 888 renderer system is using.
889 889
890 890 :param changes: changes named tuple
891 891 :param file_changes: file changes named tuple
892 892
893 893 """
894 894 new_status = ChangesetStatus.get_status_lbl(
895 895 ChangesetStatus.STATUS_UNDER_REVIEW)
896 896
897 897 changed_files = (
898 898 file_changes.added + file_changes.modified + file_changes.removed)
899 899
900 900 params = {
901 901 'under_review_label': new_status,
902 902 'added_commits': changes.added,
903 903 'removed_commits': changes.removed,
904 904 'changed_files': changed_files,
905 905 'added_files': file_changes.added,
906 906 'modified_files': file_changes.modified,
907 907 'removed_files': file_changes.removed,
908 908 }
909 909 renderer = RstTemplateRenderer()
910 910 return renderer.render('pull_request_update.mako', **params)
911 911
912 912 def edit(self, pull_request, title, description, user):
913 913 pull_request = self.__get_pull_request(pull_request)
914 914 old_data = pull_request.get_api_data(with_merge_state=False)
915 915 if pull_request.is_closed():
916 916 raise ValueError('This pull request is closed')
917 917 if title:
918 918 pull_request.title = title
919 919 pull_request.description = description
920 920 pull_request.updated_on = datetime.datetime.now()
921 921 Session().add(pull_request)
922 922 self._log_audit_action(
923 923 'repo.pull_request.edit', {'old_data': old_data},
924 924 user, pull_request)
925 925
926 926 def update_reviewers(self, pull_request, reviewer_data, user):
927 927 """
928 928 Update the reviewers in the pull request
929 929
930 930 :param pull_request: the pr to update
931 931 :param reviewer_data: list of tuples
932 932 [(user, ['reason1', 'reason2'], mandatory_flag)]
933 933 """
934 934
935 935 reviewers = {}
936 936 for user_id, reasons, mandatory in reviewer_data:
937 937 if isinstance(user_id, (int, basestring)):
938 938 user_id = self._get_user(user_id).user_id
939 939 reviewers[user_id] = {
940 940 'reasons': reasons, 'mandatory': mandatory}
941 941
942 942 reviewers_ids = set(reviewers.keys())
943 943 pull_request = self.__get_pull_request(pull_request)
944 944 current_reviewers = PullRequestReviewers.query()\
945 945 .filter(PullRequestReviewers.pull_request ==
946 946 pull_request).all()
947 947 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
948 948
949 949 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
950 950 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
951 951
952 952 log.debug("Adding %s reviewers", ids_to_add)
953 953 log.debug("Removing %s reviewers", ids_to_remove)
954 954 changed = False
955 955 for uid in ids_to_add:
956 956 changed = True
957 957 _usr = self._get_user(uid)
958 958 reviewer = PullRequestReviewers()
959 959 reviewer.user = _usr
960 960 reviewer.pull_request = pull_request
961 961 reviewer.reasons = reviewers[uid]['reasons']
962 962 # NOTE(marcink): mandatory shouldn't be changed now
963 963 # reviewer.mandatory = reviewers[uid]['reasons']
964 964 Session().add(reviewer)
965 965 self._log_audit_action(
966 966 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
967 967 user, pull_request)
968 968
969 969 for uid in ids_to_remove:
970 970 changed = True
971 971 reviewers = PullRequestReviewers.query()\
972 972 .filter(PullRequestReviewers.user_id == uid,
973 973 PullRequestReviewers.pull_request == pull_request)\
974 974 .all()
975 975 # use .all() in case we accidentally added the same person twice
976 976 # this CAN happen due to the lack of DB checks
977 977 for obj in reviewers:
978 978 old_data = obj.get_dict()
979 979 Session().delete(obj)
980 980 self._log_audit_action(
981 981 'repo.pull_request.reviewer.delete',
982 982 {'old_data': old_data}, user, pull_request)
983 983
984 984 if changed:
985 985 pull_request.updated_on = datetime.datetime.now()
986 986 Session().add(pull_request)
987 987
988 988 self.notify_reviewers(pull_request, ids_to_add)
989 989 return ids_to_add, ids_to_remove
990 990
991 991 def get_url(self, pull_request, request=None, permalink=False):
992 992 if not request:
993 993 request = get_current_request()
994 994
995 995 if permalink:
996 996 return request.route_url(
997 997 'pull_requests_global',
998 998 pull_request_id=pull_request.pull_request_id,)
999 999 else:
1000 1000 return request.route_url('pullrequest_show',
1001 1001 repo_name=safe_str(pull_request.target_repo.repo_name),
1002 1002 pull_request_id=pull_request.pull_request_id,)
1003 1003
1004 1004 def get_shadow_clone_url(self, pull_request):
1005 1005 """
1006 1006 Returns qualified url pointing to the shadow repository. If this pull
1007 1007 request is closed there is no shadow repository and ``None`` will be
1008 1008 returned.
1009 1009 """
1010 1010 if pull_request.is_closed():
1011 1011 return None
1012 1012 else:
1013 1013 pr_url = urllib.unquote(self.get_url(pull_request))
1014 1014 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1015 1015
1016 1016 def notify_reviewers(self, pull_request, reviewers_ids):
1017 1017 # notification to reviewers
1018 1018 if not reviewers_ids:
1019 1019 return
1020 1020
1021 1021 pull_request_obj = pull_request
1022 1022 # get the current participants of this pull request
1023 1023 recipients = reviewers_ids
1024 1024 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1025 1025
1026 1026 pr_source_repo = pull_request_obj.source_repo
1027 1027 pr_target_repo = pull_request_obj.target_repo
1028 1028
1029 1029 pr_url = h.route_url('pullrequest_show',
1030 1030 repo_name=pr_target_repo.repo_name,
1031 1031 pull_request_id=pull_request_obj.pull_request_id,)
1032 1032
1033 1033 # set some variables for email notification
1034 1034 pr_target_repo_url = h.route_url(
1035 1035 'repo_summary', repo_name=pr_target_repo.repo_name)
1036 1036
1037 1037 pr_source_repo_url = h.route_url(
1038 1038 'repo_summary', repo_name=pr_source_repo.repo_name)
1039 1039
1040 1040 # pull request specifics
1041 1041 pull_request_commits = [
1042 1042 (x.raw_id, x.message)
1043 1043 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1044 1044
1045 1045 kwargs = {
1046 1046 'user': pull_request.author,
1047 1047 'pull_request': pull_request_obj,
1048 1048 'pull_request_commits': pull_request_commits,
1049 1049
1050 1050 'pull_request_target_repo': pr_target_repo,
1051 1051 'pull_request_target_repo_url': pr_target_repo_url,
1052 1052
1053 1053 'pull_request_source_repo': pr_source_repo,
1054 1054 'pull_request_source_repo_url': pr_source_repo_url,
1055 1055
1056 1056 'pull_request_url': pr_url,
1057 1057 }
1058 1058
1059 1059 # pre-generate the subject for notification itself
1060 1060 (subject,
1061 1061 _h, _e, # we don't care about those
1062 1062 body_plaintext) = EmailNotificationModel().render_email(
1063 1063 notification_type, **kwargs)
1064 1064
1065 1065 # create notification objects, and emails
1066 1066 NotificationModel().create(
1067 1067 created_by=pull_request.author,
1068 1068 notification_subject=subject,
1069 1069 notification_body=body_plaintext,
1070 1070 notification_type=notification_type,
1071 1071 recipients=recipients,
1072 1072 email_kwargs=kwargs,
1073 1073 )
1074 1074
1075 1075 def delete(self, pull_request, user):
1076 1076 pull_request = self.__get_pull_request(pull_request)
1077 1077 old_data = pull_request.get_api_data(with_merge_state=False)
1078 1078 self._cleanup_merge_workspace(pull_request)
1079 1079 self._log_audit_action(
1080 1080 'repo.pull_request.delete', {'old_data': old_data},
1081 1081 user, pull_request)
1082 1082 Session().delete(pull_request)
1083 1083
1084 1084 def close_pull_request(self, pull_request, user):
1085 1085 pull_request = self.__get_pull_request(pull_request)
1086 1086 self._cleanup_merge_workspace(pull_request)
1087 1087 pull_request.status = PullRequest.STATUS_CLOSED
1088 1088 pull_request.updated_on = datetime.datetime.now()
1089 1089 Session().add(pull_request)
1090 1090 self._trigger_pull_request_hook(
1091 1091 pull_request, pull_request.author, 'close')
1092 1092 self._log_audit_action(
1093 1093 'repo.pull_request.close', {}, user, pull_request)
1094 1094
1095 1095 def close_pull_request_with_comment(
1096 1096 self, pull_request, user, repo, message=None):
1097 1097
1098 1098 pull_request_review_status = pull_request.calculated_review_status()
1099 1099
1100 1100 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1101 1101 # approved only if we have voting consent
1102 1102 status = ChangesetStatus.STATUS_APPROVED
1103 1103 else:
1104 1104 status = ChangesetStatus.STATUS_REJECTED
1105 1105 status_lbl = ChangesetStatus.get_status_lbl(status)
1106 1106
1107 1107 default_message = (
1108 1108 _('Closing with status change {transition_icon} {status}.')
1109 1109 ).format(transition_icon='>', status=status_lbl)
1110 1110 text = message or default_message
1111 1111
1112 1112 # create a comment, and link it to new status
1113 1113 comment = CommentsModel().create(
1114 1114 text=text,
1115 1115 repo=repo.repo_id,
1116 1116 user=user.user_id,
1117 1117 pull_request=pull_request.pull_request_id,
1118 1118 status_change=status_lbl,
1119 1119 status_change_type=status,
1120 1120 closing_pr=True
1121 1121 )
1122 1122
1123 1123 # calculate old status before we change it
1124 1124 old_calculated_status = pull_request.calculated_review_status()
1125 1125 ChangesetStatusModel().set_status(
1126 1126 repo.repo_id,
1127 1127 status,
1128 1128 user.user_id,
1129 1129 comment=comment,
1130 1130 pull_request=pull_request.pull_request_id
1131 1131 )
1132 1132
1133 1133 Session().flush()
1134 1134 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1135 1135 # we now calculate the status of pull request again, and based on that
1136 1136 # calculation trigger status change. This might happen in cases
1137 1137 # that non-reviewer admin closes a pr, which means his vote doesn't
1138 1138 # change the status, while if he's a reviewer this might change it.
1139 1139 calculated_status = pull_request.calculated_review_status()
1140 1140 if old_calculated_status != calculated_status:
1141 1141 self._trigger_pull_request_hook(
1142 1142 pull_request, user, 'review_status_change')
1143 1143
1144 1144 # finally close the PR
1145 1145 PullRequestModel().close_pull_request(
1146 1146 pull_request.pull_request_id, user)
1147 1147
1148 1148 return comment, status
1149 1149
1150 1150 def merge_status(self, pull_request):
1151 1151 if not self._is_merge_enabled(pull_request):
1152 1152 return False, _('Server-side pull request merging is disabled.')
1153 1153 if pull_request.is_closed():
1154 1154 return False, _('This pull request is closed.')
1155 1155 merge_possible, msg = self._check_repo_requirements(
1156 1156 target=pull_request.target_repo, source=pull_request.source_repo)
1157 1157 if not merge_possible:
1158 1158 return merge_possible, msg
1159 1159
1160 1160 try:
1161 1161 resp = self._try_merge(pull_request)
1162 1162 log.debug("Merge response: %s", resp)
1163 1163 status = resp.possible, self.merge_status_message(
1164 1164 resp.failure_reason)
1165 1165 except NotImplementedError:
1166 1166 status = False, _('Pull request merging is not supported.')
1167 1167
1168 1168 return status
1169 1169
1170 1170 def _check_repo_requirements(self, target, source):
1171 1171 """
1172 1172 Check if `target` and `source` have compatible requirements.
1173 1173
1174 1174 Currently this is just checking for largefiles.
1175 1175 """
1176 1176 target_has_largefiles = self._has_largefiles(target)
1177 1177 source_has_largefiles = self._has_largefiles(source)
1178 1178 merge_possible = True
1179 1179 message = u''
1180 1180
1181 1181 if target_has_largefiles != source_has_largefiles:
1182 1182 merge_possible = False
1183 1183 if source_has_largefiles:
1184 1184 message = _(
1185 1185 'Target repository large files support is disabled.')
1186 1186 else:
1187 1187 message = _(
1188 1188 'Source repository large files support is disabled.')
1189 1189
1190 1190 return merge_possible, message
1191 1191
1192 1192 def _has_largefiles(self, repo):
1193 1193 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1194 1194 'extensions', 'largefiles')
1195 1195 return largefiles_ui and largefiles_ui[0].active
1196 1196
1197 1197 def _try_merge(self, pull_request):
1198 1198 """
1199 1199 Try to merge the pull request and return the merge status.
1200 1200 """
1201 1201 log.debug(
1202 1202 "Trying out if the pull request %s can be merged.",
1203 1203 pull_request.pull_request_id)
1204 1204 target_vcs = pull_request.target_repo.scm_instance()
1205 1205
1206 1206 # Refresh the target reference.
1207 1207 try:
1208 1208 target_ref = self._refresh_reference(
1209 1209 pull_request.target_ref_parts, target_vcs)
1210 1210 except CommitDoesNotExistError:
1211 1211 merge_state = MergeResponse(
1212 1212 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1213 1213 return merge_state
1214 1214
1215 1215 target_locked = pull_request.target_repo.locked
1216 1216 if target_locked and target_locked[0]:
1217 1217 log.debug("The target repository is locked.")
1218 1218 merge_state = MergeResponse(
1219 1219 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1220 1220 elif self._needs_merge_state_refresh(pull_request, target_ref):
1221 1221 log.debug("Refreshing the merge status of the repository.")
1222 1222 merge_state = self._refresh_merge_state(
1223 1223 pull_request, target_vcs, target_ref)
1224 1224 else:
1225 1225 possible = pull_request.\
1226 _last_merge_status == MergeFailureReason.NONE
1226 last_merge_status == MergeFailureReason.NONE
1227 1227 merge_state = MergeResponse(
1228 possible, False, None, pull_request._last_merge_status)
1228 possible, False, None, pull_request.last_merge_status)
1229 1229
1230 1230 return merge_state
1231 1231
1232 1232 def _refresh_reference(self, reference, vcs_repository):
1233 1233 if reference.type in ('branch', 'book'):
1234 1234 name_or_id = reference.name
1235 1235 else:
1236 1236 name_or_id = reference.commit_id
1237 1237 refreshed_commit = vcs_repository.get_commit(name_or_id)
1238 1238 refreshed_reference = Reference(
1239 1239 reference.type, reference.name, refreshed_commit.raw_id)
1240 1240 return refreshed_reference
1241 1241
1242 1242 def _needs_merge_state_refresh(self, pull_request, target_reference):
1243 1243 return not(
1244 1244 pull_request.revisions and
1245 1245 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1246 1246 target_reference.commit_id == pull_request._last_merge_target_rev)
1247 1247
1248 1248 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1249 1249 workspace_id = self._workspace_id(pull_request)
1250 1250 source_vcs = pull_request.source_repo.scm_instance()
1251 1251 use_rebase = self._use_rebase_for_merging(pull_request)
1252 1252 merge_state = target_vcs.merge(
1253 1253 target_reference, source_vcs, pull_request.source_ref_parts,
1254 1254 workspace_id, dry_run=True, use_rebase=use_rebase)
1255 1255
1256 1256 # Do not store the response if there was an unknown error.
1257 1257 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1258 1258 pull_request._last_merge_source_rev = \
1259 1259 pull_request.source_ref_parts.commit_id
1260 1260 pull_request._last_merge_target_rev = target_reference.commit_id
1261 pull_request._last_merge_status = merge_state.failure_reason
1261 pull_request.last_merge_status = merge_state.failure_reason
1262 1262 pull_request.shadow_merge_ref = merge_state.merge_ref
1263 1263 Session().add(pull_request)
1264 1264 Session().commit()
1265 1265
1266 1266 return merge_state
1267 1267
1268 1268 def _workspace_id(self, pull_request):
1269 1269 workspace_id = 'pr-%s' % pull_request.pull_request_id
1270 1270 return workspace_id
1271 1271
1272 1272 def merge_status_message(self, status_code):
1273 1273 """
1274 1274 Return a human friendly error message for the given merge status code.
1275 1275 """
1276 1276 return self.MERGE_STATUS_MESSAGES[status_code]
1277 1277
1278 1278 def generate_repo_data(self, repo, commit_id=None, branch=None,
1279 1279 bookmark=None):
1280 1280 all_refs, selected_ref = \
1281 1281 self._get_repo_pullrequest_sources(
1282 1282 repo.scm_instance(), commit_id=commit_id,
1283 1283 branch=branch, bookmark=bookmark)
1284 1284
1285 1285 refs_select2 = []
1286 1286 for element in all_refs:
1287 1287 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1288 1288 refs_select2.append({'text': element[1], 'children': children})
1289 1289
1290 1290 return {
1291 1291 'user': {
1292 1292 'user_id': repo.user.user_id,
1293 1293 'username': repo.user.username,
1294 1294 'firstname': repo.user.first_name,
1295 1295 'lastname': repo.user.last_name,
1296 1296 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1297 1297 },
1298 1298 'description': h.chop_at_smart(repo.description_safe, '\n'),
1299 1299 'refs': {
1300 1300 'all_refs': all_refs,
1301 1301 'selected_ref': selected_ref,
1302 1302 'select2_refs': refs_select2
1303 1303 }
1304 1304 }
1305 1305
1306 1306 def generate_pullrequest_title(self, source, source_ref, target):
1307 1307 return u'{source}#{at_ref} to {target}'.format(
1308 1308 source=source,
1309 1309 at_ref=source_ref,
1310 1310 target=target,
1311 1311 )
1312 1312
1313 1313 def _cleanup_merge_workspace(self, pull_request):
1314 1314 # Merging related cleanup
1315 1315 target_scm = pull_request.target_repo.scm_instance()
1316 1316 workspace_id = 'pr-%s' % pull_request.pull_request_id
1317 1317
1318 1318 try:
1319 1319 target_scm.cleanup_merge_workspace(workspace_id)
1320 1320 except NotImplementedError:
1321 1321 pass
1322 1322
1323 1323 def _get_repo_pullrequest_sources(
1324 1324 self, repo, commit_id=None, branch=None, bookmark=None):
1325 1325 """
1326 1326 Return a structure with repo's interesting commits, suitable for
1327 1327 the selectors in pullrequest controller
1328 1328
1329 1329 :param commit_id: a commit that must be in the list somehow
1330 1330 and selected by default
1331 1331 :param branch: a branch that must be in the list and selected
1332 1332 by default - even if closed
1333 1333 :param bookmark: a bookmark that must be in the list and selected
1334 1334 """
1335 1335
1336 1336 commit_id = safe_str(commit_id) if commit_id else None
1337 1337 branch = safe_str(branch) if branch else None
1338 1338 bookmark = safe_str(bookmark) if bookmark else None
1339 1339
1340 1340 selected = None
1341 1341
1342 1342 # order matters: first source that has commit_id in it will be selected
1343 1343 sources = []
1344 1344 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1345 1345 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1346 1346
1347 1347 if commit_id:
1348 1348 ref_commit = (h.short_id(commit_id), commit_id)
1349 1349 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1350 1350
1351 1351 sources.append(
1352 1352 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1353 1353 )
1354 1354
1355 1355 groups = []
1356 1356 for group_key, ref_list, group_name, match in sources:
1357 1357 group_refs = []
1358 1358 for ref_name, ref_id in ref_list:
1359 1359 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1360 1360 group_refs.append((ref_key, ref_name))
1361 1361
1362 1362 if not selected:
1363 1363 if set([commit_id, match]) & set([ref_id, ref_name]):
1364 1364 selected = ref_key
1365 1365
1366 1366 if group_refs:
1367 1367 groups.append((group_refs, group_name))
1368 1368
1369 1369 if not selected:
1370 1370 ref = commit_id or branch or bookmark
1371 1371 if ref:
1372 1372 raise CommitDoesNotExistError(
1373 1373 'No commit refs could be found matching: %s' % ref)
1374 1374 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1375 1375 selected = 'branch:%s:%s' % (
1376 1376 repo.DEFAULT_BRANCH_NAME,
1377 1377 repo.branches[repo.DEFAULT_BRANCH_NAME]
1378 1378 )
1379 1379 elif repo.commit_ids:
1380 1380 rev = repo.commit_ids[0]
1381 1381 selected = 'rev:%s:%s' % (rev, rev)
1382 1382 else:
1383 1383 raise EmptyRepositoryError()
1384 1384 return groups, selected
1385 1385
1386 1386 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1387 1387 return self._get_diff_from_pr_or_version(
1388 1388 source_repo, source_ref_id, target_ref_id, context=context)
1389 1389
1390 1390 def _get_diff_from_pr_or_version(
1391 1391 self, source_repo, source_ref_id, target_ref_id, context):
1392 1392 target_commit = source_repo.get_commit(
1393 1393 commit_id=safe_str(target_ref_id))
1394 1394 source_commit = source_repo.get_commit(
1395 1395 commit_id=safe_str(source_ref_id))
1396 1396 if isinstance(source_repo, Repository):
1397 1397 vcs_repo = source_repo.scm_instance()
1398 1398 else:
1399 1399 vcs_repo = source_repo
1400 1400
1401 1401 # TODO: johbo: In the context of an update, we cannot reach
1402 1402 # the old commit anymore with our normal mechanisms. It needs
1403 1403 # some sort of special support in the vcs layer to avoid this
1404 1404 # workaround.
1405 1405 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1406 1406 vcs_repo.alias == 'git'):
1407 1407 source_commit.raw_id = safe_str(source_ref_id)
1408 1408
1409 1409 log.debug('calculating diff between '
1410 1410 'source_ref:%s and target_ref:%s for repo `%s`',
1411 1411 target_ref_id, source_ref_id,
1412 1412 safe_unicode(vcs_repo.path))
1413 1413
1414 1414 vcs_diff = vcs_repo.get_diff(
1415 1415 commit1=target_commit, commit2=source_commit, context=context)
1416 1416 return vcs_diff
1417 1417
1418 1418 def _is_merge_enabled(self, pull_request):
1419 1419 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1420 1420 settings = settings_model.get_general_settings()
1421 1421 return settings.get('rhodecode_pr_merge_enabled', False)
1422 1422
1423 1423 def _use_rebase_for_merging(self, pull_request):
1424 1424 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1425 1425 settings = settings_model.get_general_settings()
1426 1426 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1427 1427
1428 1428 def _log_audit_action(self, action, action_data, user, pull_request):
1429 1429 audit_logger.store(
1430 1430 action=action,
1431 1431 action_data=action_data,
1432 1432 user=user,
1433 1433 repo=pull_request.target_repo)
1434 1434
1435 1435 def get_reviewer_functions(self):
1436 1436 """
1437 1437 Fetches functions for validation and fetching default reviewers.
1438 1438 If available we use the EE package, else we fallback to CE
1439 1439 package functions
1440 1440 """
1441 1441 try:
1442 1442 from rc_reviewers.utils import get_default_reviewers_data
1443 1443 from rc_reviewers.utils import validate_default_reviewers
1444 1444 except ImportError:
1445 1445 from rhodecode.apps.repository.utils import \
1446 1446 get_default_reviewers_data
1447 1447 from rhodecode.apps.repository.utils import \
1448 1448 validate_default_reviewers
1449 1449
1450 1450 return get_default_reviewers_data, validate_default_reviewers
1451 1451
1452 1452
1453 1453 class MergeCheck(object):
1454 1454 """
1455 1455 Perform Merge Checks and returns a check object which stores information
1456 1456 about merge errors, and merge conditions
1457 1457 """
1458 1458 TODO_CHECK = 'todo'
1459 1459 PERM_CHECK = 'perm'
1460 1460 REVIEW_CHECK = 'review'
1461 1461 MERGE_CHECK = 'merge'
1462 1462
1463 1463 def __init__(self):
1464 1464 self.review_status = None
1465 1465 self.merge_possible = None
1466 1466 self.merge_msg = ''
1467 1467 self.failed = None
1468 1468 self.errors = []
1469 1469 self.error_details = OrderedDict()
1470 1470
1471 1471 def push_error(self, error_type, message, error_key, details):
1472 1472 self.failed = True
1473 1473 self.errors.append([error_type, message])
1474 1474 self.error_details[error_key] = dict(
1475 1475 details=details,
1476 1476 error_type=error_type,
1477 1477 message=message
1478 1478 )
1479 1479
1480 1480 @classmethod
1481 1481 def validate(cls, pull_request, user, fail_early=False, translator=None):
1482 1482 # if migrated to pyramid...
1483 1483 # _ = lambda: translator or _ # use passed in translator if any
1484 1484
1485 1485 merge_check = cls()
1486 1486
1487 1487 # permissions to merge
1488 1488 user_allowed_to_merge = PullRequestModel().check_user_merge(
1489 1489 pull_request, user)
1490 1490 if not user_allowed_to_merge:
1491 1491 log.debug("MergeCheck: cannot merge, approval is pending.")
1492 1492
1493 1493 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1494 1494 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1495 1495 if fail_early:
1496 1496 return merge_check
1497 1497
1498 1498 # review status, must be always present
1499 1499 review_status = pull_request.calculated_review_status()
1500 1500 merge_check.review_status = review_status
1501 1501
1502 1502 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1503 1503 if not status_approved:
1504 1504 log.debug("MergeCheck: cannot merge, approval is pending.")
1505 1505
1506 1506 msg = _('Pull request reviewer approval is pending.')
1507 1507
1508 1508 merge_check.push_error(
1509 1509 'warning', msg, cls.REVIEW_CHECK, review_status)
1510 1510
1511 1511 if fail_early:
1512 1512 return merge_check
1513 1513
1514 1514 # left over TODOs
1515 1515 todos = CommentsModel().get_unresolved_todos(pull_request)
1516 1516 if todos:
1517 1517 log.debug("MergeCheck: cannot merge, {} "
1518 1518 "unresolved todos left.".format(len(todos)))
1519 1519
1520 1520 if len(todos) == 1:
1521 1521 msg = _('Cannot merge, {} TODO still not resolved.').format(
1522 1522 len(todos))
1523 1523 else:
1524 1524 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1525 1525 len(todos))
1526 1526
1527 1527 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1528 1528
1529 1529 if fail_early:
1530 1530 return merge_check
1531 1531
1532 1532 # merge possible
1533 1533 merge_status, msg = PullRequestModel().merge_status(pull_request)
1534 1534 merge_check.merge_possible = merge_status
1535 1535 merge_check.merge_msg = msg
1536 1536 if not merge_status:
1537 1537 log.debug(
1538 1538 "MergeCheck: cannot merge, pull request merge not possible.")
1539 1539 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1540 1540
1541 1541 if fail_early:
1542 1542 return merge_check
1543 1543
1544 1544 return merge_check
1545 1545
1546 1546
1547 1547 ChangeTuple = namedtuple('ChangeTuple',
1548 1548 ['added', 'common', 'removed', 'total'])
1549 1549
1550 1550 FileChangeTuple = namedtuple('FileChangeTuple',
1551 1551 ['added', 'modified', 'removed'])
@@ -1,859 +1,859 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 import textwrap
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.utils2 import safe_unicode
27 27 from rhodecode.lib.vcs.backends import get_backend
28 28 from rhodecode.lib.vcs.backends.base import (
29 29 MergeResponse, MergeFailureReason, Reference)
30 30 from rhodecode.lib.vcs.exceptions import RepositoryError
31 31 from rhodecode.lib.vcs.nodes import FileNode
32 32 from rhodecode.model.comment import CommentsModel
33 33 from rhodecode.model.db import PullRequest, Session
34 34 from rhodecode.model.pull_request import PullRequestModel
35 35 from rhodecode.model.user import UserModel
36 36 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
37 37
38 38
39 39 pytestmark = [
40 40 pytest.mark.backends("git", "hg"),
41 41 ]
42 42
43 43
44 44 @pytest.mark.usefixtures('config_stub')
45 45 class TestPullRequestModel(object):
46 46
47 47 @pytest.fixture
48 48 def pull_request(self, request, backend, pr_util):
49 49 """
50 50 A pull request combined with multiples patches.
51 51 """
52 52 BackendClass = get_backend(backend.alias)
53 53 self.merge_patcher = mock.patch.object(
54 54 BackendClass, 'merge', return_value=MergeResponse(
55 55 False, False, None, MergeFailureReason.UNKNOWN))
56 56 self.workspace_remove_patcher = mock.patch.object(
57 57 BackendClass, 'cleanup_merge_workspace')
58 58
59 59 self.workspace_remove_mock = self.workspace_remove_patcher.start()
60 60 self.merge_mock = self.merge_patcher.start()
61 61 self.comment_patcher = mock.patch(
62 62 'rhodecode.model.changeset_status.ChangesetStatusModel.set_status')
63 63 self.comment_patcher.start()
64 64 self.notification_patcher = mock.patch(
65 65 'rhodecode.model.notification.NotificationModel.create')
66 66 self.notification_patcher.start()
67 67 self.helper_patcher = mock.patch(
68 68 'rhodecode.lib.helpers.url')
69 69 self.helper_patcher.start()
70 70
71 71 self.hook_patcher = mock.patch.object(PullRequestModel,
72 72 '_trigger_pull_request_hook')
73 73 self.hook_mock = self.hook_patcher.start()
74 74
75 75 self.invalidation_patcher = mock.patch(
76 76 'rhodecode.model.pull_request.ScmModel.mark_for_invalidation')
77 77 self.invalidation_mock = self.invalidation_patcher.start()
78 78
79 79 self.pull_request = pr_util.create_pull_request(
80 80 mergeable=True, name_suffix=u'Δ…Δ‡')
81 81 self.source_commit = self.pull_request.source_ref_parts.commit_id
82 82 self.target_commit = self.pull_request.target_ref_parts.commit_id
83 83 self.workspace_id = 'pr-%s' % self.pull_request.pull_request_id
84 84
85 85 @request.addfinalizer
86 86 def cleanup_pull_request():
87 87 calls = [mock.call(
88 88 self.pull_request, self.pull_request.author, 'create')]
89 89 self.hook_mock.assert_has_calls(calls)
90 90
91 91 self.workspace_remove_patcher.stop()
92 92 self.merge_patcher.stop()
93 93 self.comment_patcher.stop()
94 94 self.notification_patcher.stop()
95 95 self.helper_patcher.stop()
96 96 self.hook_patcher.stop()
97 97 self.invalidation_patcher.stop()
98 98
99 99 return self.pull_request
100 100
101 101 def test_get_all(self, pull_request):
102 102 prs = PullRequestModel().get_all(pull_request.target_repo)
103 103 assert isinstance(prs, list)
104 104 assert len(prs) == 1
105 105
106 106 def test_count_all(self, pull_request):
107 107 pr_count = PullRequestModel().count_all(pull_request.target_repo)
108 108 assert pr_count == 1
109 109
110 110 def test_get_awaiting_review(self, pull_request):
111 111 prs = PullRequestModel().get_awaiting_review(pull_request.target_repo)
112 112 assert isinstance(prs, list)
113 113 assert len(prs) == 1
114 114
115 115 def test_count_awaiting_review(self, pull_request):
116 116 pr_count = PullRequestModel().count_awaiting_review(
117 117 pull_request.target_repo)
118 118 assert pr_count == 1
119 119
120 120 def test_get_awaiting_my_review(self, pull_request):
121 121 PullRequestModel().update_reviewers(
122 122 pull_request, [(pull_request.author, ['author'], False)],
123 123 pull_request.author)
124 124 prs = PullRequestModel().get_awaiting_my_review(
125 125 pull_request.target_repo, user_id=pull_request.author.user_id)
126 126 assert isinstance(prs, list)
127 127 assert len(prs) == 1
128 128
129 129 def test_count_awaiting_my_review(self, pull_request):
130 130 PullRequestModel().update_reviewers(
131 131 pull_request, [(pull_request.author, ['author'], False)],
132 132 pull_request.author)
133 133 pr_count = PullRequestModel().count_awaiting_my_review(
134 134 pull_request.target_repo, user_id=pull_request.author.user_id)
135 135 assert pr_count == 1
136 136
137 137 def test_delete_calls_cleanup_merge(self, pull_request):
138 138 PullRequestModel().delete(pull_request, pull_request.author)
139 139
140 140 self.workspace_remove_mock.assert_called_once_with(
141 141 self.workspace_id)
142 142
143 143 def test_close_calls_cleanup_and_hook(self, pull_request):
144 144 PullRequestModel().close_pull_request(
145 145 pull_request, pull_request.author)
146 146
147 147 self.workspace_remove_mock.assert_called_once_with(
148 148 self.workspace_id)
149 149 self.hook_mock.assert_called_with(
150 150 self.pull_request, self.pull_request.author, 'close')
151 151
152 152 def test_merge_status(self, pull_request):
153 153 self.merge_mock.return_value = MergeResponse(
154 154 True, False, None, MergeFailureReason.NONE)
155 155
156 156 assert pull_request._last_merge_source_rev is None
157 157 assert pull_request._last_merge_target_rev is None
158 assert pull_request._last_merge_status is None
158 assert pull_request.last_merge_status is None
159 159
160 160 status, msg = PullRequestModel().merge_status(pull_request)
161 161 assert status is True
162 162 assert msg.eval() == 'This pull request can be automatically merged.'
163 163 self.merge_mock.assert_called_once_with(
164 164 pull_request.target_ref_parts,
165 165 pull_request.source_repo.scm_instance(),
166 166 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
167 167 use_rebase=False)
168 168
169 169 assert pull_request._last_merge_source_rev == self.source_commit
170 170 assert pull_request._last_merge_target_rev == self.target_commit
171 assert pull_request._last_merge_status is MergeFailureReason.NONE
171 assert pull_request.last_merge_status is MergeFailureReason.NONE
172 172
173 173 self.merge_mock.reset_mock()
174 174 status, msg = PullRequestModel().merge_status(pull_request)
175 175 assert status is True
176 176 assert msg.eval() == 'This pull request can be automatically merged.'
177 177 assert self.merge_mock.called is False
178 178
179 179 def test_merge_status_known_failure(self, pull_request):
180 180 self.merge_mock.return_value = MergeResponse(
181 181 False, False, None, MergeFailureReason.MERGE_FAILED)
182 182
183 183 assert pull_request._last_merge_source_rev is None
184 184 assert pull_request._last_merge_target_rev is None
185 assert pull_request._last_merge_status is None
185 assert pull_request.last_merge_status is None
186 186
187 187 status, msg = PullRequestModel().merge_status(pull_request)
188 188 assert status is False
189 189 assert (
190 190 msg.eval() ==
191 191 'This pull request cannot be merged because of merge conflicts.')
192 192 self.merge_mock.assert_called_once_with(
193 193 pull_request.target_ref_parts,
194 194 pull_request.source_repo.scm_instance(),
195 195 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
196 196 use_rebase=False)
197 197
198 198 assert pull_request._last_merge_source_rev == self.source_commit
199 199 assert pull_request._last_merge_target_rev == self.target_commit
200 200 assert (
201 pull_request._last_merge_status is MergeFailureReason.MERGE_FAILED)
201 pull_request.last_merge_status is MergeFailureReason.MERGE_FAILED)
202 202
203 203 self.merge_mock.reset_mock()
204 204 status, msg = PullRequestModel().merge_status(pull_request)
205 205 assert status is False
206 206 assert (
207 207 msg.eval() ==
208 208 'This pull request cannot be merged because of merge conflicts.')
209 209 assert self.merge_mock.called is False
210 210
211 211 def test_merge_status_unknown_failure(self, pull_request):
212 212 self.merge_mock.return_value = MergeResponse(
213 213 False, False, None, MergeFailureReason.UNKNOWN)
214 214
215 215 assert pull_request._last_merge_source_rev is None
216 216 assert pull_request._last_merge_target_rev is None
217 assert pull_request._last_merge_status is None
217 assert pull_request.last_merge_status is None
218 218
219 219 status, msg = PullRequestModel().merge_status(pull_request)
220 220 assert status is False
221 221 assert msg.eval() == (
222 222 'This pull request cannot be merged because of an unhandled'
223 223 ' exception.')
224 224 self.merge_mock.assert_called_once_with(
225 225 pull_request.target_ref_parts,
226 226 pull_request.source_repo.scm_instance(),
227 227 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
228 228 use_rebase=False)
229 229
230 230 assert pull_request._last_merge_source_rev is None
231 231 assert pull_request._last_merge_target_rev is None
232 assert pull_request._last_merge_status is None
232 assert pull_request.last_merge_status is None
233 233
234 234 self.merge_mock.reset_mock()
235 235 status, msg = PullRequestModel().merge_status(pull_request)
236 236 assert status is False
237 237 assert msg.eval() == (
238 238 'This pull request cannot be merged because of an unhandled'
239 239 ' exception.')
240 240 assert self.merge_mock.called is True
241 241
242 242 def test_merge_status_when_target_is_locked(self, pull_request):
243 243 pull_request.target_repo.locked = [1, u'12345.50', 'lock_web']
244 244 status, msg = PullRequestModel().merge_status(pull_request)
245 245 assert status is False
246 246 assert msg.eval() == (
247 247 'This pull request cannot be merged because the target repository'
248 248 ' is locked.')
249 249
250 250 def test_merge_status_requirements_check_target(self, pull_request):
251 251
252 252 def has_largefiles(self, repo):
253 253 return repo == pull_request.source_repo
254 254
255 255 patcher = mock.patch.object(
256 256 PullRequestModel, '_has_largefiles', has_largefiles)
257 257 with patcher:
258 258 status, msg = PullRequestModel().merge_status(pull_request)
259 259
260 260 assert status is False
261 261 assert msg == 'Target repository large files support is disabled.'
262 262
263 263 def test_merge_status_requirements_check_source(self, pull_request):
264 264
265 265 def has_largefiles(self, repo):
266 266 return repo == pull_request.target_repo
267 267
268 268 patcher = mock.patch.object(
269 269 PullRequestModel, '_has_largefiles', has_largefiles)
270 270 with patcher:
271 271 status, msg = PullRequestModel().merge_status(pull_request)
272 272
273 273 assert status is False
274 274 assert msg == 'Source repository large files support is disabled.'
275 275
276 276 def test_merge(self, pull_request, merge_extras):
277 277 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
278 278 merge_ref = Reference(
279 279 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
280 280 self.merge_mock.return_value = MergeResponse(
281 281 True, True, merge_ref, MergeFailureReason.NONE)
282 282
283 283 merge_extras['repository'] = pull_request.target_repo.repo_name
284 284 PullRequestModel().merge(
285 285 pull_request, pull_request.author, extras=merge_extras)
286 286
287 287 message = (
288 288 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
289 289 u'\n\n {pr_title}'.format(
290 290 pr_id=pull_request.pull_request_id,
291 291 source_repo=safe_unicode(
292 292 pull_request.source_repo.scm_instance().name),
293 293 source_ref_name=pull_request.source_ref_parts.name,
294 294 pr_title=safe_unicode(pull_request.title)
295 295 )
296 296 )
297 297 self.merge_mock.assert_called_once_with(
298 298 pull_request.target_ref_parts,
299 299 pull_request.source_repo.scm_instance(),
300 300 pull_request.source_ref_parts, self.workspace_id,
301 301 user_name=user.username, user_email=user.email, message=message,
302 302 use_rebase=False
303 303 )
304 304 self.invalidation_mock.assert_called_once_with(
305 305 pull_request.target_repo.repo_name)
306 306
307 307 self.hook_mock.assert_called_with(
308 308 self.pull_request, self.pull_request.author, 'merge')
309 309
310 310 pull_request = PullRequest.get(pull_request.pull_request_id)
311 311 assert (
312 312 pull_request.merge_rev ==
313 313 '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
314 314
315 315 def test_merge_failed(self, pull_request, merge_extras):
316 316 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
317 317 merge_ref = Reference(
318 318 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
319 319 self.merge_mock.return_value = MergeResponse(
320 320 False, False, merge_ref, MergeFailureReason.MERGE_FAILED)
321 321
322 322 merge_extras['repository'] = pull_request.target_repo.repo_name
323 323 PullRequestModel().merge(
324 324 pull_request, pull_request.author, extras=merge_extras)
325 325
326 326 message = (
327 327 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
328 328 u'\n\n {pr_title}'.format(
329 329 pr_id=pull_request.pull_request_id,
330 330 source_repo=safe_unicode(
331 331 pull_request.source_repo.scm_instance().name),
332 332 source_ref_name=pull_request.source_ref_parts.name,
333 333 pr_title=safe_unicode(pull_request.title)
334 334 )
335 335 )
336 336 self.merge_mock.assert_called_once_with(
337 337 pull_request.target_ref_parts,
338 338 pull_request.source_repo.scm_instance(),
339 339 pull_request.source_ref_parts, self.workspace_id,
340 340 user_name=user.username, user_email=user.email, message=message,
341 341 use_rebase=False
342 342 )
343 343
344 344 pull_request = PullRequest.get(pull_request.pull_request_id)
345 345 assert self.invalidation_mock.called is False
346 346 assert pull_request.merge_rev is None
347 347
348 348 def test_get_commit_ids(self, pull_request):
349 349 # The PR has been not merget yet, so expect an exception
350 350 with pytest.raises(ValueError):
351 351 PullRequestModel()._get_commit_ids(pull_request)
352 352
353 353 # Merge revision is in the revisions list
354 354 pull_request.merge_rev = pull_request.revisions[0]
355 355 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
356 356 assert commit_ids == pull_request.revisions
357 357
358 358 # Merge revision is not in the revisions list
359 359 pull_request.merge_rev = 'f000' * 10
360 360 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
361 361 assert commit_ids == pull_request.revisions + [pull_request.merge_rev]
362 362
363 363 def test_get_diff_from_pr_version(self, pull_request):
364 364 source_repo = pull_request.source_repo
365 365 source_ref_id = pull_request.source_ref_parts.commit_id
366 366 target_ref_id = pull_request.target_ref_parts.commit_id
367 367 diff = PullRequestModel()._get_diff_from_pr_or_version(
368 368 source_repo, source_ref_id, target_ref_id, context=6)
369 369 assert 'file_1' in diff.raw
370 370
371 371 def test_generate_title_returns_unicode(self):
372 372 title = PullRequestModel().generate_pullrequest_title(
373 373 source='source-dummy',
374 374 source_ref='source-ref-dummy',
375 375 target='target-dummy',
376 376 )
377 377 assert type(title) == unicode
378 378
379 379
380 380 @pytest.mark.usefixtures('config_stub')
381 381 class TestIntegrationMerge(object):
382 382 @pytest.mark.parametrize('extra_config', (
383 383 {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False},
384 384 ))
385 385 def test_merge_triggers_push_hooks(
386 386 self, pr_util, user_admin, capture_rcextensions, merge_extras,
387 387 extra_config):
388 388 pull_request = pr_util.create_pull_request(
389 389 approved=True, mergeable=True)
390 390 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
391 391 merge_extras['repository'] = pull_request.target_repo.repo_name
392 392 Session().commit()
393 393
394 394 with mock.patch.dict(rhodecode.CONFIG, extra_config, clear=False):
395 395 merge_state = PullRequestModel().merge(
396 396 pull_request, user_admin, extras=merge_extras)
397 397
398 398 assert merge_state.executed
399 399 assert 'pre_push' in capture_rcextensions
400 400 assert 'post_push' in capture_rcextensions
401 401
402 402 def test_merge_can_be_rejected_by_pre_push_hook(
403 403 self, pr_util, user_admin, capture_rcextensions, merge_extras):
404 404 pull_request = pr_util.create_pull_request(
405 405 approved=True, mergeable=True)
406 406 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
407 407 merge_extras['repository'] = pull_request.target_repo.repo_name
408 408 Session().commit()
409 409
410 410 with mock.patch('rhodecode.EXTENSIONS.PRE_PUSH_HOOK') as pre_pull:
411 411 pre_pull.side_effect = RepositoryError("Disallow push!")
412 412 merge_status = PullRequestModel().merge(
413 413 pull_request, user_admin, extras=merge_extras)
414 414
415 415 assert not merge_status.executed
416 416 assert 'pre_push' not in capture_rcextensions
417 417 assert 'post_push' not in capture_rcextensions
418 418
419 419 def test_merge_fails_if_target_is_locked(
420 420 self, pr_util, user_regular, merge_extras):
421 421 pull_request = pr_util.create_pull_request(
422 422 approved=True, mergeable=True)
423 423 locked_by = [user_regular.user_id + 1, 12345.50, 'lock_web']
424 424 pull_request.target_repo.locked = locked_by
425 425 # TODO: johbo: Check if this can work based on the database, currently
426 426 # all data is pre-computed, that's why just updating the DB is not
427 427 # enough.
428 428 merge_extras['locked_by'] = locked_by
429 429 merge_extras['repository'] = pull_request.target_repo.repo_name
430 430 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
431 431 Session().commit()
432 432 merge_status = PullRequestModel().merge(
433 433 pull_request, user_regular, extras=merge_extras)
434 434 assert not merge_status.executed
435 435
436 436
437 437 @pytest.mark.parametrize('use_outdated, inlines_count, outdated_count', [
438 438 (False, 1, 0),
439 439 (True, 0, 1),
440 440 ])
441 441 def test_outdated_comments(
442 442 pr_util, use_outdated, inlines_count, outdated_count, config_stub):
443 443 pull_request = pr_util.create_pull_request()
444 444 pr_util.create_inline_comment(file_path='not_in_updated_diff')
445 445
446 446 with outdated_comments_patcher(use_outdated) as outdated_comment_mock:
447 447 pr_util.add_one_commit()
448 448 assert_inline_comments(
449 449 pull_request, visible=inlines_count, outdated=outdated_count)
450 450 outdated_comment_mock.assert_called_with(pull_request)
451 451
452 452
453 453 @pytest.fixture
454 454 def merge_extras(user_regular):
455 455 """
456 456 Context for the vcs operation when running a merge.
457 457 """
458 458 extras = {
459 459 'ip': '127.0.0.1',
460 460 'username': user_regular.username,
461 461 'action': 'push',
462 462 'repository': 'fake_target_repo_name',
463 463 'scm': 'git',
464 464 'config': 'fake_config_ini_path',
465 465 'make_lock': None,
466 466 'locked_by': [None, None, None],
467 467 'server_url': 'http://test.example.com:5000',
468 468 'hooks': ['push', 'pull'],
469 469 'is_shadow_repo': False,
470 470 }
471 471 return extras
472 472
473 473
474 474 @pytest.mark.usefixtures('config_stub')
475 475 class TestUpdateCommentHandling(object):
476 476
477 477 @pytest.fixture(autouse=True, scope='class')
478 478 def enable_outdated_comments(self, request, pylonsapp):
479 479 config_patch = mock.patch.dict(
480 480 'rhodecode.CONFIG', {'rhodecode_use_outdated_comments': True})
481 481 config_patch.start()
482 482
483 483 @request.addfinalizer
484 484 def cleanup():
485 485 config_patch.stop()
486 486
487 487 def test_comment_stays_unflagged_on_unchanged_diff(self, pr_util):
488 488 commits = [
489 489 {'message': 'a'},
490 490 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
491 491 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
492 492 ]
493 493 pull_request = pr_util.create_pull_request(
494 494 commits=commits, target_head='a', source_head='b', revisions=['b'])
495 495 pr_util.create_inline_comment(file_path='file_b')
496 496 pr_util.add_one_commit(head='c')
497 497
498 498 assert_inline_comments(pull_request, visible=1, outdated=0)
499 499
500 500 def test_comment_stays_unflagged_on_change_above(self, pr_util):
501 501 original_content = ''.join(
502 502 ['line {}\n'.format(x) for x in range(1, 11)])
503 503 updated_content = 'new_line_at_top\n' + original_content
504 504 commits = [
505 505 {'message': 'a'},
506 506 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
507 507 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
508 508 ]
509 509 pull_request = pr_util.create_pull_request(
510 510 commits=commits, target_head='a', source_head='b', revisions=['b'])
511 511
512 512 with outdated_comments_patcher():
513 513 comment = pr_util.create_inline_comment(
514 514 line_no=u'n8', file_path='file_b')
515 515 pr_util.add_one_commit(head='c')
516 516
517 517 assert_inline_comments(pull_request, visible=1, outdated=0)
518 518 assert comment.line_no == u'n9'
519 519
520 520 def test_comment_stays_unflagged_on_change_below(self, pr_util):
521 521 original_content = ''.join(['line {}\n'.format(x) for x in range(10)])
522 522 updated_content = original_content + 'new_line_at_end\n'
523 523 commits = [
524 524 {'message': 'a'},
525 525 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
526 526 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
527 527 ]
528 528 pull_request = pr_util.create_pull_request(
529 529 commits=commits, target_head='a', source_head='b', revisions=['b'])
530 530 pr_util.create_inline_comment(file_path='file_b')
531 531 pr_util.add_one_commit(head='c')
532 532
533 533 assert_inline_comments(pull_request, visible=1, outdated=0)
534 534
535 535 @pytest.mark.parametrize('line_no', ['n4', 'o4', 'n10', 'o9'])
536 536 def test_comment_flagged_on_change_around_context(self, pr_util, line_no):
537 537 base_lines = ['line {}\n'.format(x) for x in range(1, 13)]
538 538 change_lines = list(base_lines)
539 539 change_lines.insert(6, 'line 6a added\n')
540 540
541 541 # Changes on the last line of sight
542 542 update_lines = list(change_lines)
543 543 update_lines[0] = 'line 1 changed\n'
544 544 update_lines[-1] = 'line 12 changed\n'
545 545
546 546 def file_b(lines):
547 547 return FileNode('file_b', ''.join(lines))
548 548
549 549 commits = [
550 550 {'message': 'a', 'added': [file_b(base_lines)]},
551 551 {'message': 'b', 'changed': [file_b(change_lines)]},
552 552 {'message': 'c', 'changed': [file_b(update_lines)]},
553 553 ]
554 554
555 555 pull_request = pr_util.create_pull_request(
556 556 commits=commits, target_head='a', source_head='b', revisions=['b'])
557 557 pr_util.create_inline_comment(line_no=line_no, file_path='file_b')
558 558
559 559 with outdated_comments_patcher():
560 560 pr_util.add_one_commit(head='c')
561 561 assert_inline_comments(pull_request, visible=0, outdated=1)
562 562
563 563 @pytest.mark.parametrize("change, content", [
564 564 ('changed', 'changed\n'),
565 565 ('removed', ''),
566 566 ], ids=['changed', 'removed'])
567 567 def test_comment_flagged_on_change(self, pr_util, change, content):
568 568 commits = [
569 569 {'message': 'a'},
570 570 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
571 571 {'message': 'c', change: [FileNode('file_b', content)]},
572 572 ]
573 573 pull_request = pr_util.create_pull_request(
574 574 commits=commits, target_head='a', source_head='b', revisions=['b'])
575 575 pr_util.create_inline_comment(file_path='file_b')
576 576
577 577 with outdated_comments_patcher():
578 578 pr_util.add_one_commit(head='c')
579 579 assert_inline_comments(pull_request, visible=0, outdated=1)
580 580
581 581
582 582 @pytest.mark.usefixtures('config_stub')
583 583 class TestUpdateChangedFiles(object):
584 584
585 585 def test_no_changes_on_unchanged_diff(self, pr_util):
586 586 commits = [
587 587 {'message': 'a'},
588 588 {'message': 'b',
589 589 'added': [FileNode('file_b', 'test_content b\n')]},
590 590 {'message': 'c',
591 591 'added': [FileNode('file_c', 'test_content c\n')]},
592 592 ]
593 593 # open a PR from a to b, adding file_b
594 594 pull_request = pr_util.create_pull_request(
595 595 commits=commits, target_head='a', source_head='b', revisions=['b'],
596 596 name_suffix='per-file-review')
597 597
598 598 # modify PR adding new file file_c
599 599 pr_util.add_one_commit(head='c')
600 600
601 601 assert_pr_file_changes(
602 602 pull_request,
603 603 added=['file_c'],
604 604 modified=[],
605 605 removed=[])
606 606
607 607 def test_modify_and_undo_modification_diff(self, pr_util):
608 608 commits = [
609 609 {'message': 'a'},
610 610 {'message': 'b',
611 611 'added': [FileNode('file_b', 'test_content b\n')]},
612 612 {'message': 'c',
613 613 'changed': [FileNode('file_b', 'test_content b modified\n')]},
614 614 {'message': 'd',
615 615 'changed': [FileNode('file_b', 'test_content b\n')]},
616 616 ]
617 617 # open a PR from a to b, adding file_b
618 618 pull_request = pr_util.create_pull_request(
619 619 commits=commits, target_head='a', source_head='b', revisions=['b'],
620 620 name_suffix='per-file-review')
621 621
622 622 # modify PR modifying file file_b
623 623 pr_util.add_one_commit(head='c')
624 624
625 625 assert_pr_file_changes(
626 626 pull_request,
627 627 added=[],
628 628 modified=['file_b'],
629 629 removed=[])
630 630
631 631 # move the head again to d, which rollbacks change,
632 632 # meaning we should indicate no changes
633 633 pr_util.add_one_commit(head='d')
634 634
635 635 assert_pr_file_changes(
636 636 pull_request,
637 637 added=[],
638 638 modified=[],
639 639 removed=[])
640 640
641 641 def test_updated_all_files_in_pr(self, pr_util):
642 642 commits = [
643 643 {'message': 'a'},
644 644 {'message': 'b', 'added': [
645 645 FileNode('file_a', 'test_content a\n'),
646 646 FileNode('file_b', 'test_content b\n'),
647 647 FileNode('file_c', 'test_content c\n')]},
648 648 {'message': 'c', 'changed': [
649 649 FileNode('file_a', 'test_content a changed\n'),
650 650 FileNode('file_b', 'test_content b changed\n'),
651 651 FileNode('file_c', 'test_content c changed\n')]},
652 652 ]
653 653 # open a PR from a to b, changing 3 files
654 654 pull_request = pr_util.create_pull_request(
655 655 commits=commits, target_head='a', source_head='b', revisions=['b'],
656 656 name_suffix='per-file-review')
657 657
658 658 pr_util.add_one_commit(head='c')
659 659
660 660 assert_pr_file_changes(
661 661 pull_request,
662 662 added=[],
663 663 modified=['file_a', 'file_b', 'file_c'],
664 664 removed=[])
665 665
666 666 def test_updated_and_removed_all_files_in_pr(self, pr_util):
667 667 commits = [
668 668 {'message': 'a'},
669 669 {'message': 'b', 'added': [
670 670 FileNode('file_a', 'test_content a\n'),
671 671 FileNode('file_b', 'test_content b\n'),
672 672 FileNode('file_c', 'test_content c\n')]},
673 673 {'message': 'c', 'removed': [
674 674 FileNode('file_a', 'test_content a changed\n'),
675 675 FileNode('file_b', 'test_content b changed\n'),
676 676 FileNode('file_c', 'test_content c changed\n')]},
677 677 ]
678 678 # open a PR from a to b, removing 3 files
679 679 pull_request = pr_util.create_pull_request(
680 680 commits=commits, target_head='a', source_head='b', revisions=['b'],
681 681 name_suffix='per-file-review')
682 682
683 683 pr_util.add_one_commit(head='c')
684 684
685 685 assert_pr_file_changes(
686 686 pull_request,
687 687 added=[],
688 688 modified=[],
689 689 removed=['file_a', 'file_b', 'file_c'])
690 690
691 691
692 692 def test_update_writes_snapshot_into_pull_request_version(pr_util, config_stub):
693 693 model = PullRequestModel()
694 694 pull_request = pr_util.create_pull_request()
695 695 pr_util.update_source_repository()
696 696
697 697 model.update_commits(pull_request)
698 698
699 699 # Expect that it has a version entry now
700 700 assert len(model.get_versions(pull_request)) == 1
701 701
702 702
703 703 def test_update_skips_new_version_if_unchanged(pr_util, config_stub):
704 704 pull_request = pr_util.create_pull_request()
705 705 model = PullRequestModel()
706 706 model.update_commits(pull_request)
707 707
708 708 # Expect that it still has no versions
709 709 assert len(model.get_versions(pull_request)) == 0
710 710
711 711
712 712 def test_update_assigns_comments_to_the_new_version(pr_util, config_stub):
713 713 model = PullRequestModel()
714 714 pull_request = pr_util.create_pull_request()
715 715 comment = pr_util.create_comment()
716 716 pr_util.update_source_repository()
717 717
718 718 model.update_commits(pull_request)
719 719
720 720 # Expect that the comment is linked to the pr version now
721 721 assert comment.pull_request_version == model.get_versions(pull_request)[0]
722 722
723 723
724 724 def test_update_adds_a_comment_to_the_pull_request_about_the_change(pr_util, config_stub):
725 725 model = PullRequestModel()
726 726 pull_request = pr_util.create_pull_request()
727 727 pr_util.update_source_repository()
728 728 pr_util.update_source_repository()
729 729
730 730 model.update_commits(pull_request)
731 731
732 732 # Expect to find a new comment about the change
733 733 expected_message = textwrap.dedent(
734 734 """\
735 735 Pull request updated. Auto status change to |under_review|
736 736
737 737 .. role:: added
738 738 .. role:: removed
739 739 .. parsed-literal::
740 740
741 741 Changed commits:
742 742 * :added:`1 added`
743 743 * :removed:`0 removed`
744 744
745 745 Changed files:
746 746 * `A file_2 <#a_c--92ed3b5f07b4>`_
747 747
748 748 .. |under_review| replace:: *"Under Review"*"""
749 749 )
750 750 pull_request_comments = sorted(
751 751 pull_request.comments, key=lambda c: c.modified_at)
752 752 update_comment = pull_request_comments[-1]
753 753 assert update_comment.text == expected_message
754 754
755 755
756 756 def test_create_version_from_snapshot_updates_attributes(pr_util, config_stub):
757 757 pull_request = pr_util.create_pull_request()
758 758
759 759 # Avoiding default values
760 760 pull_request.status = PullRequest.STATUS_CLOSED
761 761 pull_request._last_merge_source_rev = "0" * 40
762 762 pull_request._last_merge_target_rev = "1" * 40
763 pull_request._last_merge_status = 1
763 pull_request.last_merge_status = 1
764 764 pull_request.merge_rev = "2" * 40
765 765
766 766 # Remember automatic values
767 767 created_on = pull_request.created_on
768 768 updated_on = pull_request.updated_on
769 769
770 770 # Create a new version of the pull request
771 771 version = PullRequestModel()._create_version_from_snapshot(pull_request)
772 772
773 773 # Check attributes
774 774 assert version.title == pr_util.create_parameters['title']
775 775 assert version.description == pr_util.create_parameters['description']
776 776 assert version.status == PullRequest.STATUS_CLOSED
777 777
778 778 # versions get updated created_on
779 779 assert version.created_on != created_on
780 780
781 781 assert version.updated_on == updated_on
782 782 assert version.user_id == pull_request.user_id
783 783 assert version.revisions == pr_util.create_parameters['revisions']
784 784 assert version.source_repo == pr_util.source_repository
785 785 assert version.source_ref == pr_util.create_parameters['source_ref']
786 786 assert version.target_repo == pr_util.target_repository
787 787 assert version.target_ref == pr_util.create_parameters['target_ref']
788 788 assert version._last_merge_source_rev == pull_request._last_merge_source_rev
789 789 assert version._last_merge_target_rev == pull_request._last_merge_target_rev
790 assert version._last_merge_status == pull_request._last_merge_status
790 assert version.last_merge_status == pull_request.last_merge_status
791 791 assert version.merge_rev == pull_request.merge_rev
792 792 assert version.pull_request == pull_request
793 793
794 794
795 795 def test_link_comments_to_version_only_updates_unlinked_comments(pr_util, config_stub):
796 796 version1 = pr_util.create_version_of_pull_request()
797 797 comment_linked = pr_util.create_comment(linked_to=version1)
798 798 comment_unlinked = pr_util.create_comment()
799 799 version2 = pr_util.create_version_of_pull_request()
800 800
801 801 PullRequestModel()._link_comments_to_version(version2)
802 802
803 803 # Expect that only the new comment is linked to version2
804 804 assert (
805 805 comment_unlinked.pull_request_version_id ==
806 806 version2.pull_request_version_id)
807 807 assert (
808 808 comment_linked.pull_request_version_id ==
809 809 version1.pull_request_version_id)
810 810 assert (
811 811 comment_unlinked.pull_request_version_id !=
812 812 comment_linked.pull_request_version_id)
813 813
814 814
815 815 def test_calculate_commits():
816 816 old_ids = [1, 2, 3]
817 817 new_ids = [1, 3, 4, 5]
818 818 change = PullRequestModel()._calculate_commit_id_changes(old_ids, new_ids)
819 819 assert change.added == [4, 5]
820 820 assert change.common == [1, 3]
821 821 assert change.removed == [2]
822 822 assert change.total == [1, 3, 4, 5]
823 823
824 824
825 825 def assert_inline_comments(pull_request, visible=None, outdated=None):
826 826 if visible is not None:
827 827 inline_comments = CommentsModel().get_inline_comments(
828 828 pull_request.target_repo.repo_id, pull_request=pull_request)
829 829 inline_cnt = CommentsModel().get_inline_comments_count(
830 830 inline_comments)
831 831 assert inline_cnt == visible
832 832 if outdated is not None:
833 833 outdated_comments = CommentsModel().get_outdated_comments(
834 834 pull_request.target_repo.repo_id, pull_request)
835 835 assert len(outdated_comments) == outdated
836 836
837 837
838 838 def assert_pr_file_changes(
839 839 pull_request, added=None, modified=None, removed=None):
840 840 pr_versions = PullRequestModel().get_versions(pull_request)
841 841 # always use first version, ie original PR to calculate changes
842 842 pull_request_version = pr_versions[0]
843 843 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
844 844 pull_request, pull_request_version)
845 845 file_changes = PullRequestModel()._calculate_file_changes(
846 846 old_diff_data, new_diff_data)
847 847
848 848 assert added == file_changes.added, \
849 849 'expected added:%s vs value:%s' % (added, file_changes.added)
850 850 assert modified == file_changes.modified, \
851 851 'expected modified:%s vs value:%s' % (modified, file_changes.modified)
852 852 assert removed == file_changes.removed, \
853 853 'expected removed:%s vs value:%s' % (removed, file_changes.removed)
854 854
855 855
856 856 def outdated_comments_patcher(use_outdated=True):
857 857 return mock.patch.object(
858 858 CommentsModel, 'use_outdated_comments',
859 859 return_value=use_outdated)
General Comments 0
You need to be logged in to leave comments. Login now