##// END OF EJS Templates
pull-requests: use count only for comments related to display grids on my account and repo view.
marcink -
r4506:ed3be682 stable
parent child Browse files
Show More
@@ -1,822 +1,822 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 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 logging
22 22 import datetime
23 23 import string
24 24
25 25 import formencode
26 26 import formencode.htmlfill
27 27 import peppercorn
28 28 from pyramid.httpexceptions import HTTPFound, HTTPNotFound
29 29 from pyramid.view import view_config
30 30
31 31 from rhodecode.apps._base import BaseAppView, DataGridAppView
32 32 from rhodecode import forms
33 33 from rhodecode.lib import helpers as h
34 34 from rhodecode.lib import audit_logger
35 35 from rhodecode.lib.ext_json import json
36 36 from rhodecode.lib.auth import (
37 37 LoginRequired, NotAnonymous, CSRFRequired,
38 38 HasRepoPermissionAny, HasRepoGroupPermissionAny, AuthUser)
39 39 from rhodecode.lib.channelstream import (
40 40 channelstream_request, ChannelstreamException)
41 41 from rhodecode.lib.utils2 import safe_int, md5, str2bool
42 42 from rhodecode.model.auth_token import AuthTokenModel
43 43 from rhodecode.model.comment import CommentsModel
44 44 from rhodecode.model.db import (
45 45 IntegrityError, or_, in_filter_generator,
46 46 Repository, UserEmailMap, UserApiKeys, UserFollowing,
47 47 PullRequest, UserBookmark, RepoGroup)
48 48 from rhodecode.model.meta import Session
49 49 from rhodecode.model.pull_request import PullRequestModel
50 50 from rhodecode.model.user import UserModel
51 51 from rhodecode.model.user_group import UserGroupModel
52 52 from rhodecode.model.validation_schema.schemas import user_schema
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class MyAccountView(BaseAppView, DataGridAppView):
58 58 ALLOW_SCOPED_TOKENS = False
59 59 """
60 60 This view has alternative version inside EE, if modified please take a look
61 61 in there as well.
62 62 """
63 63
64 64 def load_default_context(self):
65 65 c = self._get_local_tmpl_context()
66 66 c.user = c.auth_user.get_instance()
67 67 c.allow_scoped_tokens = self.ALLOW_SCOPED_TOKENS
68 68
69 69 return c
70 70
71 71 @LoginRequired()
72 72 @NotAnonymous()
73 73 @view_config(
74 74 route_name='my_account_profile', request_method='GET',
75 75 renderer='rhodecode:templates/admin/my_account/my_account.mako')
76 76 def my_account_profile(self):
77 77 c = self.load_default_context()
78 78 c.active = 'profile'
79 79 c.extern_type = c.user.extern_type
80 80 return self._get_template_context(c)
81 81
82 82 @LoginRequired()
83 83 @NotAnonymous()
84 84 @view_config(
85 85 route_name='my_account_password', request_method='GET',
86 86 renderer='rhodecode:templates/admin/my_account/my_account.mako')
87 87 def my_account_password(self):
88 88 c = self.load_default_context()
89 89 c.active = 'password'
90 90 c.extern_type = c.user.extern_type
91 91
92 92 schema = user_schema.ChangePasswordSchema().bind(
93 93 username=c.user.username)
94 94
95 95 form = forms.Form(
96 96 schema,
97 97 action=h.route_path('my_account_password_update'),
98 98 buttons=(forms.buttons.save, forms.buttons.reset))
99 99
100 100 c.form = form
101 101 return self._get_template_context(c)
102 102
103 103 @LoginRequired()
104 104 @NotAnonymous()
105 105 @CSRFRequired()
106 106 @view_config(
107 107 route_name='my_account_password_update', request_method='POST',
108 108 renderer='rhodecode:templates/admin/my_account/my_account.mako')
109 109 def my_account_password_update(self):
110 110 _ = self.request.translate
111 111 c = self.load_default_context()
112 112 c.active = 'password'
113 113 c.extern_type = c.user.extern_type
114 114
115 115 schema = user_schema.ChangePasswordSchema().bind(
116 116 username=c.user.username)
117 117
118 118 form = forms.Form(
119 119 schema, buttons=(forms.buttons.save, forms.buttons.reset))
120 120
121 121 if c.extern_type != 'rhodecode':
122 122 raise HTTPFound(self.request.route_path('my_account_password'))
123 123
124 124 controls = self.request.POST.items()
125 125 try:
126 126 valid_data = form.validate(controls)
127 127 UserModel().update_user(c.user.user_id, **valid_data)
128 128 c.user.update_userdata(force_password_change=False)
129 129 Session().commit()
130 130 except forms.ValidationFailure as e:
131 131 c.form = e
132 132 return self._get_template_context(c)
133 133
134 134 except Exception:
135 135 log.exception("Exception updating password")
136 136 h.flash(_('Error occurred during update of user password'),
137 137 category='error')
138 138 else:
139 139 instance = c.auth_user.get_instance()
140 140 self.session.setdefault('rhodecode_user', {}).update(
141 141 {'password': md5(instance.password)})
142 142 self.session.save()
143 143 h.flash(_("Successfully updated password"), category='success')
144 144
145 145 raise HTTPFound(self.request.route_path('my_account_password'))
146 146
147 147 @LoginRequired()
148 148 @NotAnonymous()
149 149 @view_config(
150 150 route_name='my_account_auth_tokens', request_method='GET',
151 151 renderer='rhodecode:templates/admin/my_account/my_account.mako')
152 152 def my_account_auth_tokens(self):
153 153 _ = self.request.translate
154 154
155 155 c = self.load_default_context()
156 156 c.active = 'auth_tokens'
157 157 c.lifetime_values = AuthTokenModel.get_lifetime_values(translator=_)
158 158 c.role_values = [
159 159 (x, AuthTokenModel.cls._get_role_name(x))
160 160 for x in AuthTokenModel.cls.ROLES]
161 161 c.role_options = [(c.role_values, _("Role"))]
162 162 c.user_auth_tokens = AuthTokenModel().get_auth_tokens(
163 163 c.user.user_id, show_expired=True)
164 164 c.role_vcs = AuthTokenModel.cls.ROLE_VCS
165 165 return self._get_template_context(c)
166 166
167 167 @LoginRequired()
168 168 @NotAnonymous()
169 169 @CSRFRequired()
170 170 @view_config(
171 171 route_name='my_account_auth_tokens_view', request_method='POST', xhr=True,
172 172 renderer='json_ext')
173 173 def my_account_auth_tokens_view(self):
174 174 _ = self.request.translate
175 175 c = self.load_default_context()
176 176
177 177 auth_token_id = self.request.POST.get('auth_token_id')
178 178
179 179 if auth_token_id:
180 180 token = UserApiKeys.get_or_404(auth_token_id)
181 181 if token.user.user_id != c.user.user_id:
182 182 raise HTTPNotFound()
183 183
184 184 return {
185 185 'auth_token': token.api_key
186 186 }
187 187
188 188 def maybe_attach_token_scope(self, token):
189 189 # implemented in EE edition
190 190 pass
191 191
192 192 @LoginRequired()
193 193 @NotAnonymous()
194 194 @CSRFRequired()
195 195 @view_config(
196 196 route_name='my_account_auth_tokens_add', request_method='POST',)
197 197 def my_account_auth_tokens_add(self):
198 198 _ = self.request.translate
199 199 c = self.load_default_context()
200 200
201 201 lifetime = safe_int(self.request.POST.get('lifetime'), -1)
202 202 description = self.request.POST.get('description')
203 203 role = self.request.POST.get('role')
204 204
205 205 token = UserModel().add_auth_token(
206 206 user=c.user.user_id,
207 207 lifetime_minutes=lifetime, role=role, description=description,
208 208 scope_callback=self.maybe_attach_token_scope)
209 209 token_data = token.get_api_data()
210 210
211 211 audit_logger.store_web(
212 212 'user.edit.token.add', action_data={
213 213 'data': {'token': token_data, 'user': 'self'}},
214 214 user=self._rhodecode_user, )
215 215 Session().commit()
216 216
217 217 h.flash(_("Auth token successfully created"), category='success')
218 218 return HTTPFound(h.route_path('my_account_auth_tokens'))
219 219
220 220 @LoginRequired()
221 221 @NotAnonymous()
222 222 @CSRFRequired()
223 223 @view_config(
224 224 route_name='my_account_auth_tokens_delete', request_method='POST')
225 225 def my_account_auth_tokens_delete(self):
226 226 _ = self.request.translate
227 227 c = self.load_default_context()
228 228
229 229 del_auth_token = self.request.POST.get('del_auth_token')
230 230
231 231 if del_auth_token:
232 232 token = UserApiKeys.get_or_404(del_auth_token)
233 233 token_data = token.get_api_data()
234 234
235 235 AuthTokenModel().delete(del_auth_token, c.user.user_id)
236 236 audit_logger.store_web(
237 237 'user.edit.token.delete', action_data={
238 238 'data': {'token': token_data, 'user': 'self'}},
239 239 user=self._rhodecode_user,)
240 240 Session().commit()
241 241 h.flash(_("Auth token successfully deleted"), category='success')
242 242
243 243 return HTTPFound(h.route_path('my_account_auth_tokens'))
244 244
245 245 @LoginRequired()
246 246 @NotAnonymous()
247 247 @view_config(
248 248 route_name='my_account_emails', request_method='GET',
249 249 renderer='rhodecode:templates/admin/my_account/my_account.mako')
250 250 def my_account_emails(self):
251 251 _ = self.request.translate
252 252
253 253 c = self.load_default_context()
254 254 c.active = 'emails'
255 255
256 256 c.user_email_map = UserEmailMap.query()\
257 257 .filter(UserEmailMap.user == c.user).all()
258 258
259 259 schema = user_schema.AddEmailSchema().bind(
260 260 username=c.user.username, user_emails=c.user.emails)
261 261
262 262 form = forms.RcForm(schema,
263 263 action=h.route_path('my_account_emails_add'),
264 264 buttons=(forms.buttons.save, forms.buttons.reset))
265 265
266 266 c.form = form
267 267 return self._get_template_context(c)
268 268
269 269 @LoginRequired()
270 270 @NotAnonymous()
271 271 @CSRFRequired()
272 272 @view_config(
273 273 route_name='my_account_emails_add', request_method='POST',
274 274 renderer='rhodecode:templates/admin/my_account/my_account.mako')
275 275 def my_account_emails_add(self):
276 276 _ = self.request.translate
277 277 c = self.load_default_context()
278 278 c.active = 'emails'
279 279
280 280 schema = user_schema.AddEmailSchema().bind(
281 281 username=c.user.username, user_emails=c.user.emails)
282 282
283 283 form = forms.RcForm(
284 284 schema, action=h.route_path('my_account_emails_add'),
285 285 buttons=(forms.buttons.save, forms.buttons.reset))
286 286
287 287 controls = self.request.POST.items()
288 288 try:
289 289 valid_data = form.validate(controls)
290 290 UserModel().add_extra_email(c.user.user_id, valid_data['email'])
291 291 audit_logger.store_web(
292 292 'user.edit.email.add', action_data={
293 293 'data': {'email': valid_data['email'], 'user': 'self'}},
294 294 user=self._rhodecode_user,)
295 295 Session().commit()
296 296 except formencode.Invalid as error:
297 297 h.flash(h.escape(error.error_dict['email']), category='error')
298 298 except forms.ValidationFailure as e:
299 299 c.user_email_map = UserEmailMap.query() \
300 300 .filter(UserEmailMap.user == c.user).all()
301 301 c.form = e
302 302 return self._get_template_context(c)
303 303 except Exception:
304 304 log.exception("Exception adding email")
305 305 h.flash(_('Error occurred during adding email'),
306 306 category='error')
307 307 else:
308 308 h.flash(_("Successfully added email"), category='success')
309 309
310 310 raise HTTPFound(self.request.route_path('my_account_emails'))
311 311
312 312 @LoginRequired()
313 313 @NotAnonymous()
314 314 @CSRFRequired()
315 315 @view_config(
316 316 route_name='my_account_emails_delete', request_method='POST')
317 317 def my_account_emails_delete(self):
318 318 _ = self.request.translate
319 319 c = self.load_default_context()
320 320
321 321 del_email_id = self.request.POST.get('del_email_id')
322 322 if del_email_id:
323 323 email = UserEmailMap.get_or_404(del_email_id).email
324 324 UserModel().delete_extra_email(c.user.user_id, del_email_id)
325 325 audit_logger.store_web(
326 326 'user.edit.email.delete', action_data={
327 327 'data': {'email': email, 'user': 'self'}},
328 328 user=self._rhodecode_user,)
329 329 Session().commit()
330 330 h.flash(_("Email successfully deleted"),
331 331 category='success')
332 332 return HTTPFound(h.route_path('my_account_emails'))
333 333
334 334 @LoginRequired()
335 335 @NotAnonymous()
336 336 @CSRFRequired()
337 337 @view_config(
338 338 route_name='my_account_notifications_test_channelstream',
339 339 request_method='POST', renderer='json_ext')
340 340 def my_account_notifications_test_channelstream(self):
341 341 message = 'Test message sent via Channelstream by user: {}, on {}'.format(
342 342 self._rhodecode_user.username, datetime.datetime.now())
343 343 payload = {
344 344 # 'channel': 'broadcast',
345 345 'type': 'message',
346 346 'timestamp': datetime.datetime.utcnow(),
347 347 'user': 'system',
348 348 'pm_users': [self._rhodecode_user.username],
349 349 'message': {
350 350 'message': message,
351 351 'level': 'info',
352 352 'topic': '/notifications'
353 353 }
354 354 }
355 355
356 356 registry = self.request.registry
357 357 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
358 358 channelstream_config = rhodecode_plugins.get('channelstream', {})
359 359
360 360 try:
361 361 channelstream_request(channelstream_config, [payload], '/message')
362 362 except ChannelstreamException as e:
363 363 log.exception('Failed to send channelstream data')
364 364 return {"response": 'ERROR: {}'.format(e.__class__.__name__)}
365 365 return {"response": 'Channelstream data sent. '
366 366 'You should see a new live message now.'}
367 367
368 368 def _load_my_repos_data(self, watched=False):
369 369
370 370 allowed_ids = [-1] + self._rhodecode_user.repo_acl_ids_from_stack(AuthUser.repo_read_perms)
371 371
372 372 if watched:
373 373 # repos user watch
374 374 repo_list = Session().query(
375 375 Repository
376 376 ) \
377 377 .join(
378 378 (UserFollowing, UserFollowing.follows_repo_id == Repository.repo_id)
379 379 ) \
380 380 .filter(
381 381 UserFollowing.user_id == self._rhodecode_user.user_id
382 382 ) \
383 383 .filter(or_(
384 384 # generate multiple IN to fix limitation problems
385 385 *in_filter_generator(Repository.repo_id, allowed_ids))
386 386 ) \
387 387 .order_by(Repository.repo_name) \
388 388 .all()
389 389
390 390 else:
391 391 # repos user is owner of
392 392 repo_list = Session().query(
393 393 Repository
394 394 ) \
395 395 .filter(
396 396 Repository.user_id == self._rhodecode_user.user_id
397 397 ) \
398 398 .filter(or_(
399 399 # generate multiple IN to fix limitation problems
400 400 *in_filter_generator(Repository.repo_id, allowed_ids))
401 401 ) \
402 402 .order_by(Repository.repo_name) \
403 403 .all()
404 404
405 405 _render = self.request.get_partial_renderer(
406 406 'rhodecode:templates/data_table/_dt_elements.mako')
407 407
408 408 def repo_lnk(name, rtype, rstate, private, archived, fork_of):
409 409 return _render('repo_name', name, rtype, rstate, private, archived, fork_of,
410 410 short_name=False, admin=False)
411 411
412 412 repos_data = []
413 413 for repo in repo_list:
414 414 row = {
415 415 "name": repo_lnk(repo.repo_name, repo.repo_type, repo.repo_state,
416 416 repo.private, repo.archived, repo.fork),
417 417 "name_raw": repo.repo_name.lower(),
418 418 }
419 419
420 420 repos_data.append(row)
421 421
422 422 # json used to render the grid
423 423 return json.dumps(repos_data)
424 424
425 425 @LoginRequired()
426 426 @NotAnonymous()
427 427 @view_config(
428 428 route_name='my_account_repos', request_method='GET',
429 429 renderer='rhodecode:templates/admin/my_account/my_account.mako')
430 430 def my_account_repos(self):
431 431 c = self.load_default_context()
432 432 c.active = 'repos'
433 433
434 434 # json used to render the grid
435 435 c.data = self._load_my_repos_data()
436 436 return self._get_template_context(c)
437 437
438 438 @LoginRequired()
439 439 @NotAnonymous()
440 440 @view_config(
441 441 route_name='my_account_watched', request_method='GET',
442 442 renderer='rhodecode:templates/admin/my_account/my_account.mako')
443 443 def my_account_watched(self):
444 444 c = self.load_default_context()
445 445 c.active = 'watched'
446 446
447 447 # json used to render the grid
448 448 c.data = self._load_my_repos_data(watched=True)
449 449 return self._get_template_context(c)
450 450
451 451 @LoginRequired()
452 452 @NotAnonymous()
453 453 @view_config(
454 454 route_name='my_account_bookmarks', request_method='GET',
455 455 renderer='rhodecode:templates/admin/my_account/my_account.mako')
456 456 def my_account_bookmarks(self):
457 457 c = self.load_default_context()
458 458 c.active = 'bookmarks'
459 459 c.bookmark_items = UserBookmark.get_bookmarks_for_user(
460 460 self._rhodecode_db_user.user_id, cache=False)
461 461 return self._get_template_context(c)
462 462
463 463 def _process_bookmark_entry(self, entry, user_id):
464 464 position = safe_int(entry.get('position'))
465 465 cur_position = safe_int(entry.get('cur_position'))
466 466 if position is None:
467 467 return
468 468
469 469 # check if this is an existing entry
470 470 is_new = False
471 471 db_entry = UserBookmark().get_by_position_for_user(cur_position, user_id)
472 472
473 473 if db_entry and str2bool(entry.get('remove')):
474 474 log.debug('Marked bookmark %s for deletion', db_entry)
475 475 Session().delete(db_entry)
476 476 return
477 477
478 478 if not db_entry:
479 479 # new
480 480 db_entry = UserBookmark()
481 481 is_new = True
482 482
483 483 should_save = False
484 484 default_redirect_url = ''
485 485
486 486 # save repo
487 487 if entry.get('bookmark_repo') and safe_int(entry.get('bookmark_repo')):
488 488 repo = Repository.get(entry['bookmark_repo'])
489 489 perm_check = HasRepoPermissionAny(
490 490 'repository.read', 'repository.write', 'repository.admin')
491 491 if repo and perm_check(repo_name=repo.repo_name):
492 492 db_entry.repository = repo
493 493 should_save = True
494 494 default_redirect_url = '${repo_url}'
495 495 # save repo group
496 496 elif entry.get('bookmark_repo_group') and safe_int(entry.get('bookmark_repo_group')):
497 497 repo_group = RepoGroup.get(entry['bookmark_repo_group'])
498 498 perm_check = HasRepoGroupPermissionAny(
499 499 'group.read', 'group.write', 'group.admin')
500 500
501 501 if repo_group and perm_check(group_name=repo_group.group_name):
502 502 db_entry.repository_group = repo_group
503 503 should_save = True
504 504 default_redirect_url = '${repo_group_url}'
505 505 # save generic info
506 506 elif entry.get('title') and entry.get('redirect_url'):
507 507 should_save = True
508 508
509 509 if should_save:
510 510 # mark user and position
511 511 db_entry.user_id = user_id
512 512 db_entry.position = position
513 513 db_entry.title = entry.get('title')
514 514 db_entry.redirect_url = entry.get('redirect_url') or default_redirect_url
515 515 log.debug('Saving bookmark %s, new:%s', db_entry, is_new)
516 516
517 517 Session().add(db_entry)
518 518
519 519 @LoginRequired()
520 520 @NotAnonymous()
521 521 @CSRFRequired()
522 522 @view_config(
523 523 route_name='my_account_bookmarks_update', request_method='POST')
524 524 def my_account_bookmarks_update(self):
525 525 _ = self.request.translate
526 526 c = self.load_default_context()
527 527 c.active = 'bookmarks'
528 528
529 529 controls = peppercorn.parse(self.request.POST.items())
530 530 user_id = c.user.user_id
531 531
532 532 # validate positions
533 533 positions = {}
534 534 for entry in controls.get('bookmarks', []):
535 535 position = safe_int(entry['position'])
536 536 if position is None:
537 537 continue
538 538
539 539 if position in positions:
540 540 h.flash(_("Position {} is defined twice. "
541 541 "Please correct this error.").format(position), category='error')
542 542 return HTTPFound(h.route_path('my_account_bookmarks'))
543 543
544 544 entry['position'] = position
545 545 entry['cur_position'] = safe_int(entry.get('cur_position'))
546 546 positions[position] = entry
547 547
548 548 try:
549 549 for entry in positions.values():
550 550 self._process_bookmark_entry(entry, user_id)
551 551
552 552 Session().commit()
553 553 h.flash(_("Update Bookmarks"), category='success')
554 554 except IntegrityError:
555 555 h.flash(_("Failed to update bookmarks. "
556 556 "Make sure an unique position is used."), category='error')
557 557
558 558 return HTTPFound(h.route_path('my_account_bookmarks'))
559 559
560 560 @LoginRequired()
561 561 @NotAnonymous()
562 562 @view_config(
563 563 route_name='my_account_goto_bookmark', request_method='GET',
564 564 renderer='rhodecode:templates/admin/my_account/my_account.mako')
565 565 def my_account_goto_bookmark(self):
566 566
567 567 bookmark_id = self.request.matchdict['bookmark_id']
568 568 user_bookmark = UserBookmark().query()\
569 569 .filter(UserBookmark.user_id == self.request.user.user_id) \
570 570 .filter(UserBookmark.position == bookmark_id).scalar()
571 571
572 572 redirect_url = h.route_path('my_account_bookmarks')
573 573 if not user_bookmark:
574 574 raise HTTPFound(redirect_url)
575 575
576 576 # repository set
577 577 if user_bookmark.repository:
578 578 repo_name = user_bookmark.repository.repo_name
579 579 base_redirect_url = h.route_path(
580 580 'repo_summary', repo_name=repo_name)
581 581 if user_bookmark.redirect_url and \
582 582 '${repo_url}' in user_bookmark.redirect_url:
583 583 redirect_url = string.Template(user_bookmark.redirect_url)\
584 584 .safe_substitute({'repo_url': base_redirect_url})
585 585 else:
586 586 redirect_url = base_redirect_url
587 587 # repository group set
588 588 elif user_bookmark.repository_group:
589 589 repo_group_name = user_bookmark.repository_group.group_name
590 590 base_redirect_url = h.route_path(
591 591 'repo_group_home', repo_group_name=repo_group_name)
592 592 if user_bookmark.redirect_url and \
593 593 '${repo_group_url}' in user_bookmark.redirect_url:
594 594 redirect_url = string.Template(user_bookmark.redirect_url)\
595 595 .safe_substitute({'repo_group_url': base_redirect_url})
596 596 else:
597 597 redirect_url = base_redirect_url
598 598 # custom URL set
599 599 elif user_bookmark.redirect_url:
600 600 server_url = h.route_url('home').rstrip('/')
601 601 redirect_url = string.Template(user_bookmark.redirect_url) \
602 602 .safe_substitute({'server_url': server_url})
603 603
604 604 log.debug('Redirecting bookmark %s to %s', user_bookmark, redirect_url)
605 605 raise HTTPFound(redirect_url)
606 606
607 607 @LoginRequired()
608 608 @NotAnonymous()
609 609 @view_config(
610 610 route_name='my_account_perms', request_method='GET',
611 611 renderer='rhodecode:templates/admin/my_account/my_account.mako')
612 612 def my_account_perms(self):
613 613 c = self.load_default_context()
614 614 c.active = 'perms'
615 615
616 616 c.perm_user = c.auth_user
617 617 return self._get_template_context(c)
618 618
619 619 @LoginRequired()
620 620 @NotAnonymous()
621 621 @view_config(
622 622 route_name='my_account_notifications', request_method='GET',
623 623 renderer='rhodecode:templates/admin/my_account/my_account.mako')
624 624 def my_notifications(self):
625 625 c = self.load_default_context()
626 626 c.active = 'notifications'
627 627
628 628 return self._get_template_context(c)
629 629
630 630 @LoginRequired()
631 631 @NotAnonymous()
632 632 @CSRFRequired()
633 633 @view_config(
634 634 route_name='my_account_notifications_toggle_visibility',
635 635 request_method='POST', renderer='json_ext')
636 636 def my_notifications_toggle_visibility(self):
637 637 user = self._rhodecode_db_user
638 638 new_status = not user.user_data.get('notification_status', True)
639 639 user.update_userdata(notification_status=new_status)
640 640 Session().commit()
641 641 return user.user_data['notification_status']
642 642
643 643 @LoginRequired()
644 644 @NotAnonymous()
645 645 @view_config(
646 646 route_name='my_account_edit',
647 647 request_method='GET',
648 648 renderer='rhodecode:templates/admin/my_account/my_account.mako')
649 649 def my_account_edit(self):
650 650 c = self.load_default_context()
651 651 c.active = 'profile_edit'
652 652 c.extern_type = c.user.extern_type
653 653 c.extern_name = c.user.extern_name
654 654
655 655 schema = user_schema.UserProfileSchema().bind(
656 656 username=c.user.username, user_emails=c.user.emails)
657 657 appstruct = {
658 658 'username': c.user.username,
659 659 'email': c.user.email,
660 660 'firstname': c.user.firstname,
661 661 'lastname': c.user.lastname,
662 662 'description': c.user.description,
663 663 }
664 664 c.form = forms.RcForm(
665 665 schema, appstruct=appstruct,
666 666 action=h.route_path('my_account_update'),
667 667 buttons=(forms.buttons.save, forms.buttons.reset))
668 668
669 669 return self._get_template_context(c)
670 670
671 671 @LoginRequired()
672 672 @NotAnonymous()
673 673 @CSRFRequired()
674 674 @view_config(
675 675 route_name='my_account_update',
676 676 request_method='POST',
677 677 renderer='rhodecode:templates/admin/my_account/my_account.mako')
678 678 def my_account_update(self):
679 679 _ = self.request.translate
680 680 c = self.load_default_context()
681 681 c.active = 'profile_edit'
682 682 c.perm_user = c.auth_user
683 683 c.extern_type = c.user.extern_type
684 684 c.extern_name = c.user.extern_name
685 685
686 686 schema = user_schema.UserProfileSchema().bind(
687 687 username=c.user.username, user_emails=c.user.emails)
688 688 form = forms.RcForm(
689 689 schema, buttons=(forms.buttons.save, forms.buttons.reset))
690 690
691 691 controls = self.request.POST.items()
692 692 try:
693 693 valid_data = form.validate(controls)
694 694 skip_attrs = ['admin', 'active', 'extern_type', 'extern_name',
695 695 'new_password', 'password_confirmation']
696 696 if c.extern_type != "rhodecode":
697 697 # forbid updating username for external accounts
698 698 skip_attrs.append('username')
699 699 old_email = c.user.email
700 700 UserModel().update_user(
701 701 self._rhodecode_user.user_id, skip_attrs=skip_attrs,
702 702 **valid_data)
703 703 if old_email != valid_data['email']:
704 704 old = UserEmailMap.query() \
705 705 .filter(UserEmailMap.user == c.user).filter(UserEmailMap.email == valid_data['email']).first()
706 706 old.email = old_email
707 707 h.flash(_('Your account was updated successfully'), category='success')
708 708 Session().commit()
709 709 except forms.ValidationFailure as e:
710 710 c.form = e
711 711 return self._get_template_context(c)
712 712 except Exception:
713 713 log.exception("Exception updating user")
714 714 h.flash(_('Error occurred during update of user'),
715 715 category='error')
716 716 raise HTTPFound(h.route_path('my_account_profile'))
717 717
718 718 def _get_pull_requests_list(self, statuses):
719 719 draw, start, limit = self._extract_chunk(self.request)
720 720 search_q, order_by, order_dir = self._extract_ordering(self.request)
721 721 _render = self.request.get_partial_renderer(
722 722 'rhodecode:templates/data_table/_dt_elements.mako')
723 723
724 724 pull_requests = PullRequestModel().get_im_participating_in(
725 725 user_id=self._rhodecode_user.user_id,
726 726 statuses=statuses, query=search_q,
727 727 offset=start, length=limit, order_by=order_by,
728 728 order_dir=order_dir)
729 729
730 730 pull_requests_total_count = PullRequestModel().count_im_participating_in(
731 731 user_id=self._rhodecode_user.user_id, statuses=statuses, query=search_q)
732 732
733 733 data = []
734 734 comments_model = CommentsModel()
735 735 for pr in pull_requests:
736 736 repo_id = pr.target_repo_id
737 comments = comments_model.get_all_comments(
738 repo_id, pull_request=pr)
737 comments_count = comments_model.get_all_comments(
738 repo_id, pull_request=pr, count_only=True)
739 739 owned = pr.user_id == self._rhodecode_user.user_id
740 740
741 741 data.append({
742 742 'target_repo': _render('pullrequest_target_repo',
743 743 pr.target_repo.repo_name),
744 744 'name': _render('pullrequest_name',
745 745 pr.pull_request_id, pr.pull_request_state,
746 746 pr.work_in_progress, pr.target_repo.repo_name,
747 747 short=True),
748 748 'name_raw': pr.pull_request_id,
749 749 'status': _render('pullrequest_status',
750 750 pr.calculated_review_status()),
751 751 'title': _render('pullrequest_title', pr.title, pr.description),
752 752 'description': h.escape(pr.description),
753 753 'updated_on': _render('pullrequest_updated_on',
754 754 h.datetime_to_time(pr.updated_on)),
755 755 'updated_on_raw': h.datetime_to_time(pr.updated_on),
756 756 'created_on': _render('pullrequest_updated_on',
757 757 h.datetime_to_time(pr.created_on)),
758 758 'created_on_raw': h.datetime_to_time(pr.created_on),
759 759 'state': pr.pull_request_state,
760 760 'author': _render('pullrequest_author',
761 761 pr.author.full_contact, ),
762 762 'author_raw': pr.author.full_name,
763 'comments': _render('pullrequest_comments', len(comments)),
764 'comments_raw': len(comments),
763 'comments': _render('pullrequest_comments', comments_count),
764 'comments_raw': comments_count,
765 765 'closed': pr.is_closed(),
766 766 'owned': owned
767 767 })
768 768
769 769 # json used to render the grid
770 770 data = ({
771 771 'draw': draw,
772 772 'data': data,
773 773 'recordsTotal': pull_requests_total_count,
774 774 'recordsFiltered': pull_requests_total_count,
775 775 })
776 776 return data
777 777
778 778 @LoginRequired()
779 779 @NotAnonymous()
780 780 @view_config(
781 781 route_name='my_account_pullrequests',
782 782 request_method='GET',
783 783 renderer='rhodecode:templates/admin/my_account/my_account.mako')
784 784 def my_account_pullrequests(self):
785 785 c = self.load_default_context()
786 786 c.active = 'pullrequests'
787 787 req_get = self.request.GET
788 788
789 789 c.closed = str2bool(req_get.get('pr_show_closed'))
790 790
791 791 return self._get_template_context(c)
792 792
793 793 @LoginRequired()
794 794 @NotAnonymous()
795 795 @view_config(
796 796 route_name='my_account_pullrequests_data',
797 797 request_method='GET', renderer='json_ext')
798 798 def my_account_pullrequests_data(self):
799 799 self.load_default_context()
800 800 req_get = self.request.GET
801 801 closed = str2bool(req_get.get('closed'))
802 802
803 803 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
804 804 if closed:
805 805 statuses += [PullRequest.STATUS_CLOSED]
806 806
807 807 data = self._get_pull_requests_list(statuses=statuses)
808 808 return data
809 809
810 810 @LoginRequired()
811 811 @NotAnonymous()
812 812 @view_config(
813 813 route_name='my_account_user_group_membership',
814 814 request_method='GET',
815 815 renderer='rhodecode:templates/admin/my_account/my_account.mako')
816 816 def my_account_user_group_membership(self):
817 817 c = self.load_default_context()
818 818 c.active = 'user_group_membership'
819 819 groups = [UserGroupModel.get_user_groups_as_dict(group.users_group)
820 820 for group in self._rhodecode_db_user.group_member]
821 821 c.user_groups = json.dumps(groups)
822 822 return self._get_template_context(c)
@@ -1,1806 +1,1806 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 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 logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest, HTTPConflict)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
33 33
34 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
35 35 from rhodecode.lib.base import vcs_operation_context
36 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
37 37 from rhodecode.lib.exceptions import CommentVersionMismatch
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode, safe_int, aslist
43 43 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError)
46 46 from rhodecode.model.changeset_status import ChangesetStatusModel
47 47 from rhodecode.model.comment import CommentsModel
48 48 from rhodecode.model.db import (
49 49 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository,
50 50 PullRequestReviewers)
51 51 from rhodecode.model.forms import PullRequestForm
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
54 54 from rhodecode.model.scm import ScmModel
55 55
56 56 log = logging.getLogger(__name__)
57 57
58 58
59 59 class RepoPullRequestsView(RepoAppView, DataGridAppView):
60 60
61 61 def load_default_context(self):
62 62 c = self._get_local_tmpl_context(include_app_defaults=True)
63 63 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
64 64 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
65 65 # backward compat., we use for OLD PRs a plain renderer
66 66 c.renderer = 'plain'
67 67 return c
68 68
69 69 def _get_pull_requests_list(
70 70 self, repo_name, source, filter_type, opened_by, statuses):
71 71
72 72 draw, start, limit = self._extract_chunk(self.request)
73 73 search_q, order_by, order_dir = self._extract_ordering(self.request)
74 74 _render = self.request.get_partial_renderer(
75 75 'rhodecode:templates/data_table/_dt_elements.mako')
76 76
77 77 # pagination
78 78
79 79 if filter_type == 'awaiting_review':
80 80 pull_requests = PullRequestModel().get_awaiting_review(
81 81 repo_name, search_q=search_q, source=source, opened_by=opened_by,
82 82 statuses=statuses, offset=start, length=limit,
83 83 order_by=order_by, order_dir=order_dir)
84 84 pull_requests_total_count = PullRequestModel().count_awaiting_review(
85 85 repo_name, search_q=search_q, source=source, statuses=statuses,
86 86 opened_by=opened_by)
87 87 elif filter_type == 'awaiting_my_review':
88 88 pull_requests = PullRequestModel().get_awaiting_my_review(
89 89 repo_name, search_q=search_q, source=source, opened_by=opened_by,
90 90 user_id=self._rhodecode_user.user_id, statuses=statuses,
91 91 offset=start, length=limit, order_by=order_by,
92 92 order_dir=order_dir)
93 93 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
94 94 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
95 95 statuses=statuses, opened_by=opened_by)
96 96 else:
97 97 pull_requests = PullRequestModel().get_all(
98 98 repo_name, search_q=search_q, source=source, opened_by=opened_by,
99 99 statuses=statuses, offset=start, length=limit,
100 100 order_by=order_by, order_dir=order_dir)
101 101 pull_requests_total_count = PullRequestModel().count_all(
102 102 repo_name, search_q=search_q, source=source, statuses=statuses,
103 103 opened_by=opened_by)
104 104
105 105 data = []
106 106 comments_model = CommentsModel()
107 107 for pr in pull_requests:
108 comments = comments_model.get_all_comments(
109 self.db_repo.repo_id, pull_request=pr)
108 comments_count = comments_model.get_all_comments(
109 self.db_repo.repo_id, pull_request=pr, count_only=True)
110 110
111 111 data.append({
112 112 'name': _render('pullrequest_name',
113 113 pr.pull_request_id, pr.pull_request_state,
114 114 pr.work_in_progress, pr.target_repo.repo_name),
115 115 'name_raw': pr.pull_request_id,
116 116 'status': _render('pullrequest_status',
117 117 pr.calculated_review_status()),
118 118 'title': _render('pullrequest_title', pr.title, pr.description),
119 119 'description': h.escape(pr.description),
120 120 'updated_on': _render('pullrequest_updated_on',
121 121 h.datetime_to_time(pr.updated_on)),
122 122 'updated_on_raw': h.datetime_to_time(pr.updated_on),
123 123 'created_on': _render('pullrequest_updated_on',
124 124 h.datetime_to_time(pr.created_on)),
125 125 'created_on_raw': h.datetime_to_time(pr.created_on),
126 126 'state': pr.pull_request_state,
127 127 'author': _render('pullrequest_author',
128 128 pr.author.full_contact, ),
129 129 'author_raw': pr.author.full_name,
130 'comments': _render('pullrequest_comments', len(comments)),
131 'comments_raw': len(comments),
130 'comments': _render('pullrequest_comments', comments_count),
131 'comments_raw': comments_count,
132 132 'closed': pr.is_closed(),
133 133 })
134 134
135 135 data = ({
136 136 'draw': draw,
137 137 'data': data,
138 138 'recordsTotal': pull_requests_total_count,
139 139 'recordsFiltered': pull_requests_total_count,
140 140 })
141 141 return data
142 142
143 143 @LoginRequired()
144 144 @HasRepoPermissionAnyDecorator(
145 145 'repository.read', 'repository.write', 'repository.admin')
146 146 @view_config(
147 147 route_name='pullrequest_show_all', request_method='GET',
148 148 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
149 149 def pull_request_list(self):
150 150 c = self.load_default_context()
151 151
152 152 req_get = self.request.GET
153 153 c.source = str2bool(req_get.get('source'))
154 154 c.closed = str2bool(req_get.get('closed'))
155 155 c.my = str2bool(req_get.get('my'))
156 156 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
157 157 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
158 158
159 159 c.active = 'open'
160 160 if c.my:
161 161 c.active = 'my'
162 162 if c.closed:
163 163 c.active = 'closed'
164 164 if c.awaiting_review and not c.source:
165 165 c.active = 'awaiting'
166 166 if c.source and not c.awaiting_review:
167 167 c.active = 'source'
168 168 if c.awaiting_my_review:
169 169 c.active = 'awaiting_my'
170 170
171 171 return self._get_template_context(c)
172 172
173 173 @LoginRequired()
174 174 @HasRepoPermissionAnyDecorator(
175 175 'repository.read', 'repository.write', 'repository.admin')
176 176 @view_config(
177 177 route_name='pullrequest_show_all_data', request_method='GET',
178 178 renderer='json_ext', xhr=True)
179 179 def pull_request_list_data(self):
180 180 self.load_default_context()
181 181
182 182 # additional filters
183 183 req_get = self.request.GET
184 184 source = str2bool(req_get.get('source'))
185 185 closed = str2bool(req_get.get('closed'))
186 186 my = str2bool(req_get.get('my'))
187 187 awaiting_review = str2bool(req_get.get('awaiting_review'))
188 188 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
189 189
190 190 filter_type = 'awaiting_review' if awaiting_review \
191 191 else 'awaiting_my_review' if awaiting_my_review \
192 192 else None
193 193
194 194 opened_by = None
195 195 if my:
196 196 opened_by = [self._rhodecode_user.user_id]
197 197
198 198 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
199 199 if closed:
200 200 statuses = [PullRequest.STATUS_CLOSED]
201 201
202 202 data = self._get_pull_requests_list(
203 203 repo_name=self.db_repo_name, source=source,
204 204 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
205 205
206 206 return data
207 207
208 208 def _is_diff_cache_enabled(self, target_repo):
209 209 caching_enabled = self._get_general_setting(
210 210 target_repo, 'rhodecode_diff_cache')
211 211 log.debug('Diff caching enabled: %s', caching_enabled)
212 212 return caching_enabled
213 213
214 214 def _get_diffset(self, source_repo_name, source_repo,
215 215 ancestor_commit,
216 216 source_ref_id, target_ref_id,
217 217 target_commit, source_commit, diff_limit, file_limit,
218 218 fulldiff, hide_whitespace_changes, diff_context, use_ancestor=True):
219 219
220 220 if use_ancestor:
221 221 # we might want to not use it for versions
222 222 target_ref_id = ancestor_commit.raw_id
223 223
224 224 vcs_diff = PullRequestModel().get_diff(
225 225 source_repo, source_ref_id, target_ref_id,
226 226 hide_whitespace_changes, diff_context)
227 227
228 228 diff_processor = diffs.DiffProcessor(
229 229 vcs_diff, format='newdiff', diff_limit=diff_limit,
230 230 file_limit=file_limit, show_full_diff=fulldiff)
231 231
232 232 _parsed = diff_processor.prepare()
233 233
234 234 diffset = codeblocks.DiffSet(
235 235 repo_name=self.db_repo_name,
236 236 source_repo_name=source_repo_name,
237 237 source_node_getter=codeblocks.diffset_node_getter(target_commit),
238 238 target_node_getter=codeblocks.diffset_node_getter(source_commit),
239 239 )
240 240 diffset = self.path_filter.render_patchset_filtered(
241 241 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
242 242
243 243 return diffset
244 244
245 245 def _get_range_diffset(self, source_scm, source_repo,
246 246 commit1, commit2, diff_limit, file_limit,
247 247 fulldiff, hide_whitespace_changes, diff_context):
248 248 vcs_diff = source_scm.get_diff(
249 249 commit1, commit2,
250 250 ignore_whitespace=hide_whitespace_changes,
251 251 context=diff_context)
252 252
253 253 diff_processor = diffs.DiffProcessor(
254 254 vcs_diff, format='newdiff', diff_limit=diff_limit,
255 255 file_limit=file_limit, show_full_diff=fulldiff)
256 256
257 257 _parsed = diff_processor.prepare()
258 258
259 259 diffset = codeblocks.DiffSet(
260 260 repo_name=source_repo.repo_name,
261 261 source_node_getter=codeblocks.diffset_node_getter(commit1),
262 262 target_node_getter=codeblocks.diffset_node_getter(commit2))
263 263
264 264 diffset = self.path_filter.render_patchset_filtered(
265 265 diffset, _parsed, commit1.raw_id, commit2.raw_id)
266 266
267 267 return diffset
268 268
269 269 def register_comments_vars(self, c, pull_request, versions):
270 270 comments_model = CommentsModel()
271 271
272 272 # GENERAL COMMENTS with versions #
273 273 q = comments_model._all_general_comments_of_pull_request(pull_request)
274 274 q = q.order_by(ChangesetComment.comment_id.asc())
275 275 general_comments = q
276 276
277 277 # pick comments we want to render at current version
278 278 c.comment_versions = comments_model.aggregate_comments(
279 279 general_comments, versions, c.at_version_num)
280 280
281 281 # INLINE COMMENTS with versions #
282 282 q = comments_model._all_inline_comments_of_pull_request(pull_request)
283 283 q = q.order_by(ChangesetComment.comment_id.asc())
284 284 inline_comments = q
285 285
286 286 c.inline_versions = comments_model.aggregate_comments(
287 287 inline_comments, versions, c.at_version_num, inline=True)
288 288
289 289 # Comments inline+general
290 290 if c.at_version:
291 291 c.inline_comments_flat = c.inline_versions[c.at_version_num]['display']
292 292 c.comments = c.comment_versions[c.at_version_num]['display']
293 293 else:
294 294 c.inline_comments_flat = c.inline_versions[c.at_version_num]['until']
295 295 c.comments = c.comment_versions[c.at_version_num]['until']
296 296
297 297 return general_comments, inline_comments
298 298
299 299 @LoginRequired()
300 300 @HasRepoPermissionAnyDecorator(
301 301 'repository.read', 'repository.write', 'repository.admin')
302 302 @view_config(
303 303 route_name='pullrequest_show', request_method='GET',
304 304 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
305 305 def pull_request_show(self):
306 306 _ = self.request.translate
307 307 c = self.load_default_context()
308 308
309 309 pull_request = PullRequest.get_or_404(
310 310 self.request.matchdict['pull_request_id'])
311 311 pull_request_id = pull_request.pull_request_id
312 312
313 313 c.state_progressing = pull_request.is_state_changing()
314 314 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
315 315
316 316 _new_state = {
317 317 'created': PullRequest.STATE_CREATED,
318 318 }.get(self.request.GET.get('force_state'))
319 319
320 320 if c.is_super_admin and _new_state:
321 321 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
322 322 h.flash(
323 323 _('Pull Request state was force changed to `{}`').format(_new_state),
324 324 category='success')
325 325 Session().commit()
326 326
327 327 raise HTTPFound(h.route_path(
328 328 'pullrequest_show', repo_name=self.db_repo_name,
329 329 pull_request_id=pull_request_id))
330 330
331 331 version = self.request.GET.get('version')
332 332 from_version = self.request.GET.get('from_version') or version
333 333 merge_checks = self.request.GET.get('merge_checks')
334 334 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
335 335 force_refresh = str2bool(self.request.GET.get('force_refresh'))
336 336 c.range_diff_on = self.request.GET.get('range-diff') == "1"
337 337
338 338 # fetch global flags of ignore ws or context lines
339 339 diff_context = diffs.get_diff_context(self.request)
340 340 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
341 341
342 342 (pull_request_latest,
343 343 pull_request_at_ver,
344 344 pull_request_display_obj,
345 345 at_version) = PullRequestModel().get_pr_version(
346 346 pull_request_id, version=version)
347 347
348 348 pr_closed = pull_request_latest.is_closed()
349 349
350 350 if pr_closed and (version or from_version):
351 351 # not allow to browse versions for closed PR
352 352 raise HTTPFound(h.route_path(
353 353 'pullrequest_show', repo_name=self.db_repo_name,
354 354 pull_request_id=pull_request_id))
355 355
356 356 versions = pull_request_display_obj.versions()
357 357 # used to store per-commit range diffs
358 358 c.changes = collections.OrderedDict()
359 359
360 360 c.at_version = at_version
361 361 c.at_version_num = (at_version
362 362 if at_version and at_version != PullRequest.LATEST_VER
363 363 else None)
364 364
365 365 c.at_version_index = ChangesetComment.get_index_from_version(
366 366 c.at_version_num, versions)
367 367
368 368 (prev_pull_request_latest,
369 369 prev_pull_request_at_ver,
370 370 prev_pull_request_display_obj,
371 371 prev_at_version) = PullRequestModel().get_pr_version(
372 372 pull_request_id, version=from_version)
373 373
374 374 c.from_version = prev_at_version
375 375 c.from_version_num = (prev_at_version
376 376 if prev_at_version and prev_at_version != PullRequest.LATEST_VER
377 377 else None)
378 378 c.from_version_index = ChangesetComment.get_index_from_version(
379 379 c.from_version_num, versions)
380 380
381 381 # define if we're in COMPARE mode or VIEW at version mode
382 382 compare = at_version != prev_at_version
383 383
384 384 # pull_requests repo_name we opened it against
385 385 # ie. target_repo must match
386 386 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
387 387 log.warning('Mismatch between the current repo: %s, and target %s',
388 388 self.db_repo_name, pull_request_at_ver.target_repo.repo_name)
389 389 raise HTTPNotFound()
390 390
391 391 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(pull_request_at_ver)
392 392
393 393 c.pull_request = pull_request_display_obj
394 394 c.renderer = pull_request_at_ver.description_renderer or c.renderer
395 395 c.pull_request_latest = pull_request_latest
396 396
397 397 # inject latest version
398 398 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
399 399 c.versions = versions + [latest_ver]
400 400
401 401 if compare or (at_version and not at_version == PullRequest.LATEST_VER):
402 402 c.allowed_to_change_status = False
403 403 c.allowed_to_update = False
404 404 c.allowed_to_merge = False
405 405 c.allowed_to_delete = False
406 406 c.allowed_to_comment = False
407 407 c.allowed_to_close = False
408 408 else:
409 409 can_change_status = PullRequestModel().check_user_change_status(
410 410 pull_request_at_ver, self._rhodecode_user)
411 411 c.allowed_to_change_status = can_change_status and not pr_closed
412 412
413 413 c.allowed_to_update = PullRequestModel().check_user_update(
414 414 pull_request_latest, self._rhodecode_user) and not pr_closed
415 415 c.allowed_to_merge = PullRequestModel().check_user_merge(
416 416 pull_request_latest, self._rhodecode_user) and not pr_closed
417 417 c.allowed_to_delete = PullRequestModel().check_user_delete(
418 418 pull_request_latest, self._rhodecode_user) and not pr_closed
419 419 c.allowed_to_comment = not pr_closed
420 420 c.allowed_to_close = c.allowed_to_merge and not pr_closed
421 421
422 422 c.forbid_adding_reviewers = False
423 423 c.forbid_author_to_review = False
424 424 c.forbid_commit_author_to_review = False
425 425
426 426 if pull_request_latest.reviewer_data and \
427 427 'rules' in pull_request_latest.reviewer_data:
428 428 rules = pull_request_latest.reviewer_data['rules'] or {}
429 429 try:
430 430 c.forbid_adding_reviewers = rules.get('forbid_adding_reviewers')
431 431 c.forbid_author_to_review = rules.get('forbid_author_to_review')
432 432 c.forbid_commit_author_to_review = rules.get('forbid_commit_author_to_review')
433 433 except Exception:
434 434 pass
435 435
436 436 # check merge capabilities
437 437 _merge_check = MergeCheck.validate(
438 438 pull_request_latest, auth_user=self._rhodecode_user,
439 439 translator=self.request.translate,
440 440 force_shadow_repo_refresh=force_refresh)
441 441
442 442 c.pr_merge_errors = _merge_check.error_details
443 443 c.pr_merge_possible = not _merge_check.failed
444 444 c.pr_merge_message = _merge_check.merge_msg
445 445 c.pr_merge_source_commit = _merge_check.source_commit
446 446 c.pr_merge_target_commit = _merge_check.target_commit
447 447
448 448 c.pr_merge_info = MergeCheck.get_merge_conditions(
449 449 pull_request_latest, translator=self.request.translate)
450 450
451 451 c.pull_request_review_status = _merge_check.review_status
452 452 if merge_checks:
453 453 self.request.override_renderer = \
454 454 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
455 455 return self._get_template_context(c)
456 456
457 457 c.allowed_reviewers = [obj.user_id for obj in pull_request.reviewers if obj.user]
458 458 c.reviewers_count = pull_request.reviewers_count
459 459 c.observers_count = pull_request.observers_count
460 460
461 461 # reviewers and statuses
462 462 c.pull_request_default_reviewers_data_json = json.dumps(pull_request.reviewer_data)
463 463 c.pull_request_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
464 464 c.pull_request_set_observers_data_json = collections.OrderedDict({'observers': []})
465 465
466 466 for review_obj, member, reasons, mandatory, status in pull_request_at_ver.reviewers_statuses():
467 467 member_reviewer = h.reviewer_as_json(
468 468 member, reasons=reasons, mandatory=mandatory,
469 469 role=review_obj.role,
470 470 user_group=review_obj.rule_user_group_data()
471 471 )
472 472
473 473 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
474 474 member_reviewer['review_status'] = current_review_status
475 475 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
476 476 member_reviewer['allowed_to_update'] = c.allowed_to_update
477 477 c.pull_request_set_reviewers_data_json['reviewers'].append(member_reviewer)
478 478
479 479 c.pull_request_set_reviewers_data_json = json.dumps(c.pull_request_set_reviewers_data_json)
480 480
481 481 for observer_obj, member in pull_request_at_ver.observers():
482 482 member_observer = h.reviewer_as_json(
483 483 member, reasons=[], mandatory=False,
484 484 role=observer_obj.role,
485 485 user_group=observer_obj.rule_user_group_data()
486 486 )
487 487 member_observer['allowed_to_update'] = c.allowed_to_update
488 488 c.pull_request_set_observers_data_json['observers'].append(member_observer)
489 489
490 490 c.pull_request_set_observers_data_json = json.dumps(c.pull_request_set_observers_data_json)
491 491
492 492 general_comments, inline_comments = \
493 493 self.register_comments_vars(c, pull_request_latest, versions)
494 494
495 495 # TODOs
496 496 c.unresolved_comments = CommentsModel() \
497 497 .get_pull_request_unresolved_todos(pull_request_latest)
498 498 c.resolved_comments = CommentsModel() \
499 499 .get_pull_request_resolved_todos(pull_request_latest)
500 500
501 501 # if we use version, then do not show later comments
502 502 # than current version
503 503 display_inline_comments = collections.defaultdict(
504 504 lambda: collections.defaultdict(list))
505 505 for co in inline_comments:
506 506 if c.at_version_num:
507 507 # pick comments that are at least UPTO given version, so we
508 508 # don't render comments for higher version
509 509 should_render = co.pull_request_version_id and \
510 510 co.pull_request_version_id <= c.at_version_num
511 511 else:
512 512 # showing all, for 'latest'
513 513 should_render = True
514 514
515 515 if should_render:
516 516 display_inline_comments[co.f_path][co.line_no].append(co)
517 517
518 518 # load diff data into template context, if we use compare mode then
519 519 # diff is calculated based on changes between versions of PR
520 520
521 521 source_repo = pull_request_at_ver.source_repo
522 522 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
523 523
524 524 target_repo = pull_request_at_ver.target_repo
525 525 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
526 526
527 527 if compare:
528 528 # in compare switch the diff base to latest commit from prev version
529 529 target_ref_id = prev_pull_request_display_obj.revisions[0]
530 530
531 531 # despite opening commits for bookmarks/branches/tags, we always
532 532 # convert this to rev to prevent changes after bookmark or branch change
533 533 c.source_ref_type = 'rev'
534 534 c.source_ref = source_ref_id
535 535
536 536 c.target_ref_type = 'rev'
537 537 c.target_ref = target_ref_id
538 538
539 539 c.source_repo = source_repo
540 540 c.target_repo = target_repo
541 541
542 542 c.commit_ranges = []
543 543 source_commit = EmptyCommit()
544 544 target_commit = EmptyCommit()
545 545 c.missing_requirements = False
546 546
547 547 source_scm = source_repo.scm_instance()
548 548 target_scm = target_repo.scm_instance()
549 549
550 550 shadow_scm = None
551 551 try:
552 552 shadow_scm = pull_request_latest.get_shadow_repo()
553 553 except Exception:
554 554 log.debug('Failed to get shadow repo', exc_info=True)
555 555 # try first the existing source_repo, and then shadow
556 556 # repo if we can obtain one
557 557 commits_source_repo = source_scm
558 558 if shadow_scm:
559 559 commits_source_repo = shadow_scm
560 560
561 561 c.commits_source_repo = commits_source_repo
562 562 c.ancestor = None # set it to None, to hide it from PR view
563 563
564 564 # empty version means latest, so we keep this to prevent
565 565 # double caching
566 566 version_normalized = version or PullRequest.LATEST_VER
567 567 from_version_normalized = from_version or PullRequest.LATEST_VER
568 568
569 569 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
570 570 cache_file_path = diff_cache_exist(
571 571 cache_path, 'pull_request', pull_request_id, version_normalized,
572 572 from_version_normalized, source_ref_id, target_ref_id,
573 573 hide_whitespace_changes, diff_context, c.fulldiff)
574 574
575 575 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
576 576 force_recache = self.get_recache_flag()
577 577
578 578 cached_diff = None
579 579 if caching_enabled:
580 580 cached_diff = load_cached_diff(cache_file_path)
581 581
582 582 has_proper_commit_cache = (
583 583 cached_diff and cached_diff.get('commits')
584 584 and len(cached_diff.get('commits', [])) == 5
585 585 and cached_diff.get('commits')[0]
586 586 and cached_diff.get('commits')[3])
587 587
588 588 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
589 589 diff_commit_cache = \
590 590 (ancestor_commit, commit_cache, missing_requirements,
591 591 source_commit, target_commit) = cached_diff['commits']
592 592 else:
593 593 # NOTE(marcink): we reach potentially unreachable errors when a PR has
594 594 # merge errors resulting in potentially hidden commits in the shadow repo.
595 595 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
596 596 and _merge_check.merge_response
597 597 maybe_unreachable = maybe_unreachable \
598 598 and _merge_check.merge_response.metadata.get('unresolved_files')
599 599 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
600 600 diff_commit_cache = \
601 601 (ancestor_commit, commit_cache, missing_requirements,
602 602 source_commit, target_commit) = self.get_commits(
603 603 commits_source_repo,
604 604 pull_request_at_ver,
605 605 source_commit,
606 606 source_ref_id,
607 607 source_scm,
608 608 target_commit,
609 609 target_ref_id,
610 610 target_scm,
611 611 maybe_unreachable=maybe_unreachable)
612 612
613 613 # register our commit range
614 614 for comm in commit_cache.values():
615 615 c.commit_ranges.append(comm)
616 616
617 617 c.missing_requirements = missing_requirements
618 618 c.ancestor_commit = ancestor_commit
619 619 c.statuses = source_repo.statuses(
620 620 [x.raw_id for x in c.commit_ranges])
621 621
622 622 # auto collapse if we have more than limit
623 623 collapse_limit = diffs.DiffProcessor._collapse_commits_over
624 624 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
625 625 c.compare_mode = compare
626 626
627 627 # diff_limit is the old behavior, will cut off the whole diff
628 628 # if the limit is applied otherwise will just hide the
629 629 # big files from the front-end
630 630 diff_limit = c.visual.cut_off_limit_diff
631 631 file_limit = c.visual.cut_off_limit_file
632 632
633 633 c.missing_commits = False
634 634 if (c.missing_requirements
635 635 or isinstance(source_commit, EmptyCommit)
636 636 or source_commit == target_commit):
637 637
638 638 c.missing_commits = True
639 639 else:
640 640 c.inline_comments = display_inline_comments
641 641
642 642 use_ancestor = True
643 643 if from_version_normalized != version_normalized:
644 644 use_ancestor = False
645 645
646 646 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
647 647 if not force_recache and has_proper_diff_cache:
648 648 c.diffset = cached_diff['diff']
649 649 else:
650 650 try:
651 651 c.diffset = self._get_diffset(
652 652 c.source_repo.repo_name, commits_source_repo,
653 653 c.ancestor_commit,
654 654 source_ref_id, target_ref_id,
655 655 target_commit, source_commit,
656 656 diff_limit, file_limit, c.fulldiff,
657 657 hide_whitespace_changes, diff_context,
658 658 use_ancestor=use_ancestor
659 659 )
660 660
661 661 # save cached diff
662 662 if caching_enabled:
663 663 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
664 664 except CommitDoesNotExistError:
665 665 log.exception('Failed to generate diffset')
666 666 c.missing_commits = True
667 667
668 668 if not c.missing_commits:
669 669
670 670 c.limited_diff = c.diffset.limited_diff
671 671
672 672 # calculate removed files that are bound to comments
673 673 comment_deleted_files = [
674 674 fname for fname in display_inline_comments
675 675 if fname not in c.diffset.file_stats]
676 676
677 677 c.deleted_files_comments = collections.defaultdict(dict)
678 678 for fname, per_line_comments in display_inline_comments.items():
679 679 if fname in comment_deleted_files:
680 680 c.deleted_files_comments[fname]['stats'] = 0
681 681 c.deleted_files_comments[fname]['comments'] = list()
682 682 for lno, comments in per_line_comments.items():
683 683 c.deleted_files_comments[fname]['comments'].extend(comments)
684 684
685 685 # maybe calculate the range diff
686 686 if c.range_diff_on:
687 687 # TODO(marcink): set whitespace/context
688 688 context_lcl = 3
689 689 ign_whitespace_lcl = False
690 690
691 691 for commit in c.commit_ranges:
692 692 commit2 = commit
693 693 commit1 = commit.first_parent
694 694
695 695 range_diff_cache_file_path = diff_cache_exist(
696 696 cache_path, 'diff', commit.raw_id,
697 697 ign_whitespace_lcl, context_lcl, c.fulldiff)
698 698
699 699 cached_diff = None
700 700 if caching_enabled:
701 701 cached_diff = load_cached_diff(range_diff_cache_file_path)
702 702
703 703 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
704 704 if not force_recache and has_proper_diff_cache:
705 705 diffset = cached_diff['diff']
706 706 else:
707 707 diffset = self._get_range_diffset(
708 708 commits_source_repo, source_repo,
709 709 commit1, commit2, diff_limit, file_limit,
710 710 c.fulldiff, ign_whitespace_lcl, context_lcl
711 711 )
712 712
713 713 # save cached diff
714 714 if caching_enabled:
715 715 cache_diff(range_diff_cache_file_path, diffset, None)
716 716
717 717 c.changes[commit.raw_id] = diffset
718 718
719 719 # this is a hack to properly display links, when creating PR, the
720 720 # compare view and others uses different notation, and
721 721 # compare_commits.mako renders links based on the target_repo.
722 722 # We need to swap that here to generate it properly on the html side
723 723 c.target_repo = c.source_repo
724 724
725 725 c.commit_statuses = ChangesetStatus.STATUSES
726 726
727 727 c.show_version_changes = not pr_closed
728 728 if c.show_version_changes:
729 729 cur_obj = pull_request_at_ver
730 730 prev_obj = prev_pull_request_at_ver
731 731
732 732 old_commit_ids = prev_obj.revisions
733 733 new_commit_ids = cur_obj.revisions
734 734 commit_changes = PullRequestModel()._calculate_commit_id_changes(
735 735 old_commit_ids, new_commit_ids)
736 736 c.commit_changes_summary = commit_changes
737 737
738 738 # calculate the diff for commits between versions
739 739 c.commit_changes = []
740 740
741 741 def mark(cs, fw):
742 742 return list(h.itertools.izip_longest([], cs, fillvalue=fw))
743 743
744 744 for c_type, raw_id in mark(commit_changes.added, 'a') \
745 745 + mark(commit_changes.removed, 'r') \
746 746 + mark(commit_changes.common, 'c'):
747 747
748 748 if raw_id in commit_cache:
749 749 commit = commit_cache[raw_id]
750 750 else:
751 751 try:
752 752 commit = commits_source_repo.get_commit(raw_id)
753 753 except CommitDoesNotExistError:
754 754 # in case we fail extracting still use "dummy" commit
755 755 # for display in commit diff
756 756 commit = h.AttributeDict(
757 757 {'raw_id': raw_id,
758 758 'message': 'EMPTY or MISSING COMMIT'})
759 759 c.commit_changes.append([c_type, commit])
760 760
761 761 # current user review statuses for each version
762 762 c.review_versions = {}
763 763 if self._rhodecode_user.user_id in c.allowed_reviewers:
764 764 for co in general_comments:
765 765 if co.author.user_id == self._rhodecode_user.user_id:
766 766 status = co.status_change
767 767 if status:
768 768 _ver_pr = status[0].comment.pull_request_version_id
769 769 c.review_versions[_ver_pr] = status[0]
770 770
771 771 return self._get_template_context(c)
772 772
773 773 def get_commits(
774 774 self, commits_source_repo, pull_request_at_ver, source_commit,
775 775 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
776 776 maybe_unreachable=False):
777 777
778 778 commit_cache = collections.OrderedDict()
779 779 missing_requirements = False
780 780
781 781 try:
782 782 pre_load = ["author", "date", "message", "branch", "parents"]
783 783
784 784 pull_request_commits = pull_request_at_ver.revisions
785 785 log.debug('Loading %s commits from %s',
786 786 len(pull_request_commits), commits_source_repo)
787 787
788 788 for rev in pull_request_commits:
789 789 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
790 790 maybe_unreachable=maybe_unreachable)
791 791 commit_cache[comm.raw_id] = comm
792 792
793 793 # Order here matters, we first need to get target, and then
794 794 # the source
795 795 target_commit = commits_source_repo.get_commit(
796 796 commit_id=safe_str(target_ref_id))
797 797
798 798 source_commit = commits_source_repo.get_commit(
799 799 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
800 800 except CommitDoesNotExistError:
801 801 log.warning('Failed to get commit from `{}` repo'.format(
802 802 commits_source_repo), exc_info=True)
803 803 except RepositoryRequirementError:
804 804 log.warning('Failed to get all required data from repo', exc_info=True)
805 805 missing_requirements = True
806 806
807 807 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
808 808
809 809 try:
810 810 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
811 811 except Exception:
812 812 ancestor_commit = None
813 813
814 814 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
815 815
816 816 def assure_not_empty_repo(self):
817 817 _ = self.request.translate
818 818
819 819 try:
820 820 self.db_repo.scm_instance().get_commit()
821 821 except EmptyRepositoryError:
822 822 h.flash(h.literal(_('There are no commits yet')),
823 823 category='warning')
824 824 raise HTTPFound(
825 825 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
826 826
827 827 @LoginRequired()
828 828 @NotAnonymous()
829 829 @HasRepoPermissionAnyDecorator(
830 830 'repository.read', 'repository.write', 'repository.admin')
831 831 @view_config(
832 832 route_name='pullrequest_new', request_method='GET',
833 833 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
834 834 def pull_request_new(self):
835 835 _ = self.request.translate
836 836 c = self.load_default_context()
837 837
838 838 self.assure_not_empty_repo()
839 839 source_repo = self.db_repo
840 840
841 841 commit_id = self.request.GET.get('commit')
842 842 branch_ref = self.request.GET.get('branch')
843 843 bookmark_ref = self.request.GET.get('bookmark')
844 844
845 845 try:
846 846 source_repo_data = PullRequestModel().generate_repo_data(
847 847 source_repo, commit_id=commit_id,
848 848 branch=branch_ref, bookmark=bookmark_ref,
849 849 translator=self.request.translate)
850 850 except CommitDoesNotExistError as e:
851 851 log.exception(e)
852 852 h.flash(_('Commit does not exist'), 'error')
853 853 raise HTTPFound(
854 854 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
855 855
856 856 default_target_repo = source_repo
857 857
858 858 if source_repo.parent and c.has_origin_repo_read_perm:
859 859 parent_vcs_obj = source_repo.parent.scm_instance()
860 860 if parent_vcs_obj and not parent_vcs_obj.is_empty():
861 861 # change default if we have a parent repo
862 862 default_target_repo = source_repo.parent
863 863
864 864 target_repo_data = PullRequestModel().generate_repo_data(
865 865 default_target_repo, translator=self.request.translate)
866 866
867 867 selected_source_ref = source_repo_data['refs']['selected_ref']
868 868 title_source_ref = ''
869 869 if selected_source_ref:
870 870 title_source_ref = selected_source_ref.split(':', 2)[1]
871 871 c.default_title = PullRequestModel().generate_pullrequest_title(
872 872 source=source_repo.repo_name,
873 873 source_ref=title_source_ref,
874 874 target=default_target_repo.repo_name
875 875 )
876 876
877 877 c.default_repo_data = {
878 878 'source_repo_name': source_repo.repo_name,
879 879 'source_refs_json': json.dumps(source_repo_data),
880 880 'target_repo_name': default_target_repo.repo_name,
881 881 'target_refs_json': json.dumps(target_repo_data),
882 882 }
883 883 c.default_source_ref = selected_source_ref
884 884
885 885 return self._get_template_context(c)
886 886
887 887 @LoginRequired()
888 888 @NotAnonymous()
889 889 @HasRepoPermissionAnyDecorator(
890 890 'repository.read', 'repository.write', 'repository.admin')
891 891 @view_config(
892 892 route_name='pullrequest_repo_refs', request_method='GET',
893 893 renderer='json_ext', xhr=True)
894 894 def pull_request_repo_refs(self):
895 895 self.load_default_context()
896 896 target_repo_name = self.request.matchdict['target_repo_name']
897 897 repo = Repository.get_by_repo_name(target_repo_name)
898 898 if not repo:
899 899 raise HTTPNotFound()
900 900
901 901 target_perm = HasRepoPermissionAny(
902 902 'repository.read', 'repository.write', 'repository.admin')(
903 903 target_repo_name)
904 904 if not target_perm:
905 905 raise HTTPNotFound()
906 906
907 907 return PullRequestModel().generate_repo_data(
908 908 repo, translator=self.request.translate)
909 909
910 910 @LoginRequired()
911 911 @NotAnonymous()
912 912 @HasRepoPermissionAnyDecorator(
913 913 'repository.read', 'repository.write', 'repository.admin')
914 914 @view_config(
915 915 route_name='pullrequest_repo_targets', request_method='GET',
916 916 renderer='json_ext', xhr=True)
917 917 def pullrequest_repo_targets(self):
918 918 _ = self.request.translate
919 919 filter_query = self.request.GET.get('query')
920 920
921 921 # get the parents
922 922 parent_target_repos = []
923 923 if self.db_repo.parent:
924 924 parents_query = Repository.query() \
925 925 .order_by(func.length(Repository.repo_name)) \
926 926 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
927 927
928 928 if filter_query:
929 929 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
930 930 parents_query = parents_query.filter(
931 931 Repository.repo_name.ilike(ilike_expression))
932 932 parents = parents_query.limit(20).all()
933 933
934 934 for parent in parents:
935 935 parent_vcs_obj = parent.scm_instance()
936 936 if parent_vcs_obj and not parent_vcs_obj.is_empty():
937 937 parent_target_repos.append(parent)
938 938
939 939 # get other forks, and repo itself
940 940 query = Repository.query() \
941 941 .order_by(func.length(Repository.repo_name)) \
942 942 .filter(
943 943 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
944 944 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
945 945 ) \
946 946 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
947 947
948 948 if filter_query:
949 949 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
950 950 query = query.filter(Repository.repo_name.ilike(ilike_expression))
951 951
952 952 limit = max(20 - len(parent_target_repos), 5) # not less then 5
953 953 target_repos = query.limit(limit).all()
954 954
955 955 all_target_repos = target_repos + parent_target_repos
956 956
957 957 repos = []
958 958 # This checks permissions to the repositories
959 959 for obj in ScmModel().get_repos(all_target_repos):
960 960 repos.append({
961 961 'id': obj['name'],
962 962 'text': obj['name'],
963 963 'type': 'repo',
964 964 'repo_id': obj['dbrepo']['repo_id'],
965 965 'repo_type': obj['dbrepo']['repo_type'],
966 966 'private': obj['dbrepo']['private'],
967 967
968 968 })
969 969
970 970 data = {
971 971 'more': False,
972 972 'results': [{
973 973 'text': _('Repositories'),
974 974 'children': repos
975 975 }] if repos else []
976 976 }
977 977 return data
978 978
979 979 def _get_existing_ids(self, post_data):
980 980 return filter(lambda e: e, map(safe_int, aslist(post_data.get('comments'), ',')))
981 981
982 982 @LoginRequired()
983 983 @NotAnonymous()
984 984 @HasRepoPermissionAnyDecorator(
985 985 'repository.read', 'repository.write', 'repository.admin')
986 986 @view_config(
987 987 route_name='pullrequest_comments', request_method='POST',
988 988 renderer='string_html', xhr=True)
989 989 def pullrequest_comments(self):
990 990 self.load_default_context()
991 991
992 992 pull_request = PullRequest.get_or_404(
993 993 self.request.matchdict['pull_request_id'])
994 994 pull_request_id = pull_request.pull_request_id
995 995 version = self.request.GET.get('version')
996 996
997 997 _render = self.request.get_partial_renderer(
998 998 'rhodecode:templates/base/sidebar.mako')
999 999 c = _render.get_call_context()
1000 1000
1001 1001 (pull_request_latest,
1002 1002 pull_request_at_ver,
1003 1003 pull_request_display_obj,
1004 1004 at_version) = PullRequestModel().get_pr_version(
1005 1005 pull_request_id, version=version)
1006 1006 versions = pull_request_display_obj.versions()
1007 1007 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1008 1008 c.versions = versions + [latest_ver]
1009 1009
1010 1010 c.at_version = at_version
1011 1011 c.at_version_num = (at_version
1012 1012 if at_version and at_version != PullRequest.LATEST_VER
1013 1013 else None)
1014 1014
1015 1015 self.register_comments_vars(c, pull_request_latest, versions)
1016 1016 all_comments = c.inline_comments_flat + c.comments
1017 1017
1018 1018 existing_ids = self._get_existing_ids(self.request.POST)
1019 1019 return _render('comments_table', all_comments, len(all_comments),
1020 1020 existing_ids=existing_ids)
1021 1021
1022 1022 @LoginRequired()
1023 1023 @NotAnonymous()
1024 1024 @HasRepoPermissionAnyDecorator(
1025 1025 'repository.read', 'repository.write', 'repository.admin')
1026 1026 @view_config(
1027 1027 route_name='pullrequest_todos', request_method='POST',
1028 1028 renderer='string_html', xhr=True)
1029 1029 def pullrequest_todos(self):
1030 1030 self.load_default_context()
1031 1031
1032 1032 pull_request = PullRequest.get_or_404(
1033 1033 self.request.matchdict['pull_request_id'])
1034 1034 pull_request_id = pull_request.pull_request_id
1035 1035 version = self.request.GET.get('version')
1036 1036
1037 1037 _render = self.request.get_partial_renderer(
1038 1038 'rhodecode:templates/base/sidebar.mako')
1039 1039 c = _render.get_call_context()
1040 1040 (pull_request_latest,
1041 1041 pull_request_at_ver,
1042 1042 pull_request_display_obj,
1043 1043 at_version) = PullRequestModel().get_pr_version(
1044 1044 pull_request_id, version=version)
1045 1045 versions = pull_request_display_obj.versions()
1046 1046 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1047 1047 c.versions = versions + [latest_ver]
1048 1048
1049 1049 c.at_version = at_version
1050 1050 c.at_version_num = (at_version
1051 1051 if at_version and at_version != PullRequest.LATEST_VER
1052 1052 else None)
1053 1053
1054 1054 c.unresolved_comments = CommentsModel() \
1055 1055 .get_pull_request_unresolved_todos(pull_request)
1056 1056 c.resolved_comments = CommentsModel() \
1057 1057 .get_pull_request_resolved_todos(pull_request)
1058 1058
1059 1059 all_comments = c.unresolved_comments + c.resolved_comments
1060 1060 existing_ids = self._get_existing_ids(self.request.POST)
1061 1061 return _render('comments_table', all_comments, len(c.unresolved_comments),
1062 1062 todo_comments=True, existing_ids=existing_ids)
1063 1063
1064 1064 @LoginRequired()
1065 1065 @NotAnonymous()
1066 1066 @HasRepoPermissionAnyDecorator(
1067 1067 'repository.read', 'repository.write', 'repository.admin')
1068 1068 @CSRFRequired()
1069 1069 @view_config(
1070 1070 route_name='pullrequest_create', request_method='POST',
1071 1071 renderer=None)
1072 1072 def pull_request_create(self):
1073 1073 _ = self.request.translate
1074 1074 self.assure_not_empty_repo()
1075 1075 self.load_default_context()
1076 1076
1077 1077 controls = peppercorn.parse(self.request.POST.items())
1078 1078
1079 1079 try:
1080 1080 form = PullRequestForm(
1081 1081 self.request.translate, self.db_repo.repo_id)()
1082 1082 _form = form.to_python(controls)
1083 1083 except formencode.Invalid as errors:
1084 1084 if errors.error_dict.get('revisions'):
1085 1085 msg = 'Revisions: %s' % errors.error_dict['revisions']
1086 1086 elif errors.error_dict.get('pullrequest_title'):
1087 1087 msg = errors.error_dict.get('pullrequest_title')
1088 1088 else:
1089 1089 msg = _('Error creating pull request: {}').format(errors)
1090 1090 log.exception(msg)
1091 1091 h.flash(msg, 'error')
1092 1092
1093 1093 # would rather just go back to form ...
1094 1094 raise HTTPFound(
1095 1095 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1096 1096
1097 1097 source_repo = _form['source_repo']
1098 1098 source_ref = _form['source_ref']
1099 1099 target_repo = _form['target_repo']
1100 1100 target_ref = _form['target_ref']
1101 1101 commit_ids = _form['revisions'][::-1]
1102 1102 common_ancestor_id = _form['common_ancestor']
1103 1103
1104 1104 # find the ancestor for this pr
1105 1105 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
1106 1106 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
1107 1107
1108 1108 if not (source_db_repo or target_db_repo):
1109 1109 h.flash(_('source_repo or target repo not found'), category='error')
1110 1110 raise HTTPFound(
1111 1111 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1112 1112
1113 1113 # re-check permissions again here
1114 1114 # source_repo we must have read permissions
1115 1115
1116 1116 source_perm = HasRepoPermissionAny(
1117 1117 'repository.read', 'repository.write', 'repository.admin')(
1118 1118 source_db_repo.repo_name)
1119 1119 if not source_perm:
1120 1120 msg = _('Not Enough permissions to source repo `{}`.'.format(
1121 1121 source_db_repo.repo_name))
1122 1122 h.flash(msg, category='error')
1123 1123 # copy the args back to redirect
1124 1124 org_query = self.request.GET.mixed()
1125 1125 raise HTTPFound(
1126 1126 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1127 1127 _query=org_query))
1128 1128
1129 1129 # target repo we must have read permissions, and also later on
1130 1130 # we want to check branch permissions here
1131 1131 target_perm = HasRepoPermissionAny(
1132 1132 'repository.read', 'repository.write', 'repository.admin')(
1133 1133 target_db_repo.repo_name)
1134 1134 if not target_perm:
1135 1135 msg = _('Not Enough permissions to target repo `{}`.'.format(
1136 1136 target_db_repo.repo_name))
1137 1137 h.flash(msg, category='error')
1138 1138 # copy the args back to redirect
1139 1139 org_query = self.request.GET.mixed()
1140 1140 raise HTTPFound(
1141 1141 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1142 1142 _query=org_query))
1143 1143
1144 1144 source_scm = source_db_repo.scm_instance()
1145 1145 target_scm = target_db_repo.scm_instance()
1146 1146
1147 1147 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
1148 1148 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
1149 1149
1150 1150 ancestor = source_scm.get_common_ancestor(
1151 1151 source_commit.raw_id, target_commit.raw_id, target_scm)
1152 1152
1153 1153 # recalculate target ref based on ancestor
1154 1154 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
1155 1155 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
1156 1156
1157 1157 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1158 1158 PullRequestModel().get_reviewer_functions()
1159 1159
1160 1160 # recalculate reviewers logic, to make sure we can validate this
1161 1161 reviewer_rules = get_default_reviewers_data(
1162 1162 self._rhodecode_db_user, source_db_repo,
1163 1163 source_commit, target_db_repo, target_commit)
1164 1164
1165 1165 reviewers = validate_default_reviewers(_form['review_members'], reviewer_rules)
1166 1166 observers = validate_observers(_form['observer_members'], reviewer_rules)
1167 1167
1168 1168 pullrequest_title = _form['pullrequest_title']
1169 1169 title_source_ref = source_ref.split(':', 2)[1]
1170 1170 if not pullrequest_title:
1171 1171 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1172 1172 source=source_repo,
1173 1173 source_ref=title_source_ref,
1174 1174 target=target_repo
1175 1175 )
1176 1176
1177 1177 description = _form['pullrequest_desc']
1178 1178 description_renderer = _form['description_renderer']
1179 1179
1180 1180 try:
1181 1181 pull_request = PullRequestModel().create(
1182 1182 created_by=self._rhodecode_user.user_id,
1183 1183 source_repo=source_repo,
1184 1184 source_ref=source_ref,
1185 1185 target_repo=target_repo,
1186 1186 target_ref=target_ref,
1187 1187 revisions=commit_ids,
1188 1188 common_ancestor_id=common_ancestor_id,
1189 1189 reviewers=reviewers,
1190 1190 observers=observers,
1191 1191 title=pullrequest_title,
1192 1192 description=description,
1193 1193 description_renderer=description_renderer,
1194 1194 reviewer_data=reviewer_rules,
1195 1195 auth_user=self._rhodecode_user
1196 1196 )
1197 1197 Session().commit()
1198 1198
1199 1199 h.flash(_('Successfully opened new pull request'),
1200 1200 category='success')
1201 1201 except Exception:
1202 1202 msg = _('Error occurred during creation of this pull request.')
1203 1203 log.exception(msg)
1204 1204 h.flash(msg, category='error')
1205 1205
1206 1206 # copy the args back to redirect
1207 1207 org_query = self.request.GET.mixed()
1208 1208 raise HTTPFound(
1209 1209 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1210 1210 _query=org_query))
1211 1211
1212 1212 raise HTTPFound(
1213 1213 h.route_path('pullrequest_show', repo_name=target_repo,
1214 1214 pull_request_id=pull_request.pull_request_id))
1215 1215
1216 1216 @LoginRequired()
1217 1217 @NotAnonymous()
1218 1218 @HasRepoPermissionAnyDecorator(
1219 1219 'repository.read', 'repository.write', 'repository.admin')
1220 1220 @CSRFRequired()
1221 1221 @view_config(
1222 1222 route_name='pullrequest_update', request_method='POST',
1223 1223 renderer='json_ext')
1224 1224 def pull_request_update(self):
1225 1225 pull_request = PullRequest.get_or_404(
1226 1226 self.request.matchdict['pull_request_id'])
1227 1227 _ = self.request.translate
1228 1228
1229 1229 c = self.load_default_context()
1230 1230 redirect_url = None
1231 1231
1232 1232 if pull_request.is_closed():
1233 1233 log.debug('update: forbidden because pull request is closed')
1234 1234 msg = _(u'Cannot update closed pull requests.')
1235 1235 h.flash(msg, category='error')
1236 1236 return {'response': True,
1237 1237 'redirect_url': redirect_url}
1238 1238
1239 1239 is_state_changing = pull_request.is_state_changing()
1240 1240 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
1241 1241
1242 1242 # only owner or admin can update it
1243 1243 allowed_to_update = PullRequestModel().check_user_update(
1244 1244 pull_request, self._rhodecode_user)
1245 1245
1246 1246 if allowed_to_update:
1247 1247 controls = peppercorn.parse(self.request.POST.items())
1248 1248 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1249 1249
1250 1250 if 'review_members' in controls:
1251 1251 self._update_reviewers(
1252 1252 c,
1253 1253 pull_request, controls['review_members'],
1254 1254 pull_request.reviewer_data,
1255 1255 PullRequestReviewers.ROLE_REVIEWER)
1256 1256 elif 'observer_members' in controls:
1257 1257 self._update_reviewers(
1258 1258 c,
1259 1259 pull_request, controls['observer_members'],
1260 1260 pull_request.reviewer_data,
1261 1261 PullRequestReviewers.ROLE_OBSERVER)
1262 1262 elif str2bool(self.request.POST.get('update_commits', 'false')):
1263 1263 if is_state_changing:
1264 1264 log.debug('commits update: forbidden because pull request is in state %s',
1265 1265 pull_request.pull_request_state)
1266 1266 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1267 1267 u'Current state is: `{}`').format(
1268 1268 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1269 1269 h.flash(msg, category='error')
1270 1270 return {'response': True,
1271 1271 'redirect_url': redirect_url}
1272 1272
1273 1273 self._update_commits(c, pull_request)
1274 1274 if force_refresh:
1275 1275 redirect_url = h.route_path(
1276 1276 'pullrequest_show', repo_name=self.db_repo_name,
1277 1277 pull_request_id=pull_request.pull_request_id,
1278 1278 _query={"force_refresh": 1})
1279 1279 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1280 1280 self._edit_pull_request(pull_request)
1281 1281 else:
1282 1282 log.error('Unhandled update data.')
1283 1283 raise HTTPBadRequest()
1284 1284
1285 1285 return {'response': True,
1286 1286 'redirect_url': redirect_url}
1287 1287 raise HTTPForbidden()
1288 1288
1289 1289 def _edit_pull_request(self, pull_request):
1290 1290 """
1291 1291 Edit title and description
1292 1292 """
1293 1293 _ = self.request.translate
1294 1294
1295 1295 try:
1296 1296 PullRequestModel().edit(
1297 1297 pull_request,
1298 1298 self.request.POST.get('title'),
1299 1299 self.request.POST.get('description'),
1300 1300 self.request.POST.get('description_renderer'),
1301 1301 self._rhodecode_user)
1302 1302 except ValueError:
1303 1303 msg = _(u'Cannot update closed pull requests.')
1304 1304 h.flash(msg, category='error')
1305 1305 return
1306 1306 else:
1307 1307 Session().commit()
1308 1308
1309 1309 msg = _(u'Pull request title & description updated.')
1310 1310 h.flash(msg, category='success')
1311 1311 return
1312 1312
1313 1313 def _update_commits(self, c, pull_request):
1314 1314 _ = self.request.translate
1315 1315
1316 1316 with pull_request.set_state(PullRequest.STATE_UPDATING):
1317 1317 resp = PullRequestModel().update_commits(
1318 1318 pull_request, self._rhodecode_db_user)
1319 1319
1320 1320 if resp.executed:
1321 1321
1322 1322 if resp.target_changed and resp.source_changed:
1323 1323 changed = 'target and source repositories'
1324 1324 elif resp.target_changed and not resp.source_changed:
1325 1325 changed = 'target repository'
1326 1326 elif not resp.target_changed and resp.source_changed:
1327 1327 changed = 'source repository'
1328 1328 else:
1329 1329 changed = 'nothing'
1330 1330
1331 1331 msg = _(u'Pull request updated to "{source_commit_id}" with '
1332 1332 u'{count_added} added, {count_removed} removed commits. '
1333 1333 u'Source of changes: {change_source}.')
1334 1334 msg = msg.format(
1335 1335 source_commit_id=pull_request.source_ref_parts.commit_id,
1336 1336 count_added=len(resp.changes.added),
1337 1337 count_removed=len(resp.changes.removed),
1338 1338 change_source=changed)
1339 1339 h.flash(msg, category='success')
1340 1340 channelstream.pr_update_channelstream_push(
1341 1341 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1342 1342 else:
1343 1343 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1344 1344 warning_reasons = [
1345 1345 UpdateFailureReason.NO_CHANGE,
1346 1346 UpdateFailureReason.WRONG_REF_TYPE,
1347 1347 ]
1348 1348 category = 'warning' if resp.reason in warning_reasons else 'error'
1349 1349 h.flash(msg, category=category)
1350 1350
1351 1351 def _update_reviewers(self, c, pull_request, review_members, reviewer_rules, role):
1352 1352 _ = self.request.translate
1353 1353
1354 1354 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1355 1355 PullRequestModel().get_reviewer_functions()
1356 1356
1357 1357 if role == PullRequestReviewers.ROLE_REVIEWER:
1358 1358 try:
1359 1359 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1360 1360 except ValueError as e:
1361 1361 log.error('Reviewers Validation: {}'.format(e))
1362 1362 h.flash(e, category='error')
1363 1363 return
1364 1364
1365 1365 old_calculated_status = pull_request.calculated_review_status()
1366 1366 PullRequestModel().update_reviewers(
1367 1367 pull_request, reviewers, self._rhodecode_user)
1368 1368
1369 1369 Session().commit()
1370 1370
1371 1371 msg = _('Pull request reviewers updated.')
1372 1372 h.flash(msg, category='success')
1373 1373 channelstream.pr_update_channelstream_push(
1374 1374 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1375 1375
1376 1376 # trigger status changed if change in reviewers changes the status
1377 1377 calculated_status = pull_request.calculated_review_status()
1378 1378 if old_calculated_status != calculated_status:
1379 1379 PullRequestModel().trigger_pull_request_hook(
1380 1380 pull_request, self._rhodecode_user, 'review_status_change',
1381 1381 data={'status': calculated_status})
1382 1382
1383 1383 elif role == PullRequestReviewers.ROLE_OBSERVER:
1384 1384 try:
1385 1385 observers = validate_observers(review_members, reviewer_rules)
1386 1386 except ValueError as e:
1387 1387 log.error('Observers Validation: {}'.format(e))
1388 1388 h.flash(e, category='error')
1389 1389 return
1390 1390
1391 1391 PullRequestModel().update_observers(
1392 1392 pull_request, observers, self._rhodecode_user)
1393 1393
1394 1394 Session().commit()
1395 1395 msg = _('Pull request observers updated.')
1396 1396 h.flash(msg, category='success')
1397 1397 channelstream.pr_update_channelstream_push(
1398 1398 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1399 1399
1400 1400 @LoginRequired()
1401 1401 @NotAnonymous()
1402 1402 @HasRepoPermissionAnyDecorator(
1403 1403 'repository.read', 'repository.write', 'repository.admin')
1404 1404 @CSRFRequired()
1405 1405 @view_config(
1406 1406 route_name='pullrequest_merge', request_method='POST',
1407 1407 renderer='json_ext')
1408 1408 def pull_request_merge(self):
1409 1409 """
1410 1410 Merge will perform a server-side merge of the specified
1411 1411 pull request, if the pull request is approved and mergeable.
1412 1412 After successful merging, the pull request is automatically
1413 1413 closed, with a relevant comment.
1414 1414 """
1415 1415 pull_request = PullRequest.get_or_404(
1416 1416 self.request.matchdict['pull_request_id'])
1417 1417 _ = self.request.translate
1418 1418
1419 1419 if pull_request.is_state_changing():
1420 1420 log.debug('show: forbidden because pull request is in state %s',
1421 1421 pull_request.pull_request_state)
1422 1422 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1423 1423 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1424 1424 pull_request.pull_request_state)
1425 1425 h.flash(msg, category='error')
1426 1426 raise HTTPFound(
1427 1427 h.route_path('pullrequest_show',
1428 1428 repo_name=pull_request.target_repo.repo_name,
1429 1429 pull_request_id=pull_request.pull_request_id))
1430 1430
1431 1431 self.load_default_context()
1432 1432
1433 1433 with pull_request.set_state(PullRequest.STATE_UPDATING):
1434 1434 check = MergeCheck.validate(
1435 1435 pull_request, auth_user=self._rhodecode_user,
1436 1436 translator=self.request.translate)
1437 1437 merge_possible = not check.failed
1438 1438
1439 1439 for err_type, error_msg in check.errors:
1440 1440 h.flash(error_msg, category=err_type)
1441 1441
1442 1442 if merge_possible:
1443 1443 log.debug("Pre-conditions checked, trying to merge.")
1444 1444 extras = vcs_operation_context(
1445 1445 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1446 1446 username=self._rhodecode_db_user.username, action='push',
1447 1447 scm=pull_request.target_repo.repo_type)
1448 1448 with pull_request.set_state(PullRequest.STATE_UPDATING):
1449 1449 self._merge_pull_request(
1450 1450 pull_request, self._rhodecode_db_user, extras)
1451 1451 else:
1452 1452 log.debug("Pre-conditions failed, NOT merging.")
1453 1453
1454 1454 raise HTTPFound(
1455 1455 h.route_path('pullrequest_show',
1456 1456 repo_name=pull_request.target_repo.repo_name,
1457 1457 pull_request_id=pull_request.pull_request_id))
1458 1458
1459 1459 def _merge_pull_request(self, pull_request, user, extras):
1460 1460 _ = self.request.translate
1461 1461 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1462 1462
1463 1463 if merge_resp.executed:
1464 1464 log.debug("The merge was successful, closing the pull request.")
1465 1465 PullRequestModel().close_pull_request(
1466 1466 pull_request.pull_request_id, user)
1467 1467 Session().commit()
1468 1468 msg = _('Pull request was successfully merged and closed.')
1469 1469 h.flash(msg, category='success')
1470 1470 else:
1471 1471 log.debug(
1472 1472 "The merge was not successful. Merge response: %s", merge_resp)
1473 1473 msg = merge_resp.merge_status_message
1474 1474 h.flash(msg, category='error')
1475 1475
1476 1476 @LoginRequired()
1477 1477 @NotAnonymous()
1478 1478 @HasRepoPermissionAnyDecorator(
1479 1479 'repository.read', 'repository.write', 'repository.admin')
1480 1480 @CSRFRequired()
1481 1481 @view_config(
1482 1482 route_name='pullrequest_delete', request_method='POST',
1483 1483 renderer='json_ext')
1484 1484 def pull_request_delete(self):
1485 1485 _ = self.request.translate
1486 1486
1487 1487 pull_request = PullRequest.get_or_404(
1488 1488 self.request.matchdict['pull_request_id'])
1489 1489 self.load_default_context()
1490 1490
1491 1491 pr_closed = pull_request.is_closed()
1492 1492 allowed_to_delete = PullRequestModel().check_user_delete(
1493 1493 pull_request, self._rhodecode_user) and not pr_closed
1494 1494
1495 1495 # only owner can delete it !
1496 1496 if allowed_to_delete:
1497 1497 PullRequestModel().delete(pull_request, self._rhodecode_user)
1498 1498 Session().commit()
1499 1499 h.flash(_('Successfully deleted pull request'),
1500 1500 category='success')
1501 1501 raise HTTPFound(h.route_path('pullrequest_show_all',
1502 1502 repo_name=self.db_repo_name))
1503 1503
1504 1504 log.warning('user %s tried to delete pull request without access',
1505 1505 self._rhodecode_user)
1506 1506 raise HTTPNotFound()
1507 1507
1508 1508 @LoginRequired()
1509 1509 @NotAnonymous()
1510 1510 @HasRepoPermissionAnyDecorator(
1511 1511 'repository.read', 'repository.write', 'repository.admin')
1512 1512 @CSRFRequired()
1513 1513 @view_config(
1514 1514 route_name='pullrequest_comment_create', request_method='POST',
1515 1515 renderer='json_ext')
1516 1516 def pull_request_comment_create(self):
1517 1517 _ = self.request.translate
1518 1518
1519 1519 pull_request = PullRequest.get_or_404(
1520 1520 self.request.matchdict['pull_request_id'])
1521 1521 pull_request_id = pull_request.pull_request_id
1522 1522
1523 1523 if pull_request.is_closed():
1524 1524 log.debug('comment: forbidden because pull request is closed')
1525 1525 raise HTTPForbidden()
1526 1526
1527 1527 allowed_to_comment = PullRequestModel().check_user_comment(
1528 1528 pull_request, self._rhodecode_user)
1529 1529 if not allowed_to_comment:
1530 1530 log.debug('comment: forbidden because pull request is from forbidden repo')
1531 1531 raise HTTPForbidden()
1532 1532
1533 1533 c = self.load_default_context()
1534 1534
1535 1535 status = self.request.POST.get('changeset_status', None)
1536 1536 text = self.request.POST.get('text')
1537 1537 comment_type = self.request.POST.get('comment_type')
1538 1538 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1539 1539 close_pull_request = self.request.POST.get('close_pull_request')
1540 1540
1541 1541 # the logic here should work like following, if we submit close
1542 1542 # pr comment, use `close_pull_request_with_comment` function
1543 1543 # else handle regular comment logic
1544 1544
1545 1545 if close_pull_request:
1546 1546 # only owner or admin or person with write permissions
1547 1547 allowed_to_close = PullRequestModel().check_user_update(
1548 1548 pull_request, self._rhodecode_user)
1549 1549 if not allowed_to_close:
1550 1550 log.debug('comment: forbidden because not allowed to close '
1551 1551 'pull request %s', pull_request_id)
1552 1552 raise HTTPForbidden()
1553 1553
1554 1554 # This also triggers `review_status_change`
1555 1555 comment, status = PullRequestModel().close_pull_request_with_comment(
1556 1556 pull_request, self._rhodecode_user, self.db_repo, message=text,
1557 1557 auth_user=self._rhodecode_user)
1558 1558 Session().flush()
1559 1559
1560 1560 PullRequestModel().trigger_pull_request_hook(
1561 1561 pull_request, self._rhodecode_user, 'comment',
1562 1562 data={'comment': comment})
1563 1563
1564 1564 else:
1565 1565 # regular comment case, could be inline, or one with status.
1566 1566 # for that one we check also permissions
1567 1567
1568 1568 allowed_to_change_status = PullRequestModel().check_user_change_status(
1569 1569 pull_request, self._rhodecode_user)
1570 1570
1571 1571 if status and allowed_to_change_status:
1572 1572 message = (_('Status change %(transition_icon)s %(status)s')
1573 1573 % {'transition_icon': '>',
1574 1574 'status': ChangesetStatus.get_status_lbl(status)})
1575 1575 text = text or message
1576 1576
1577 1577 comment = CommentsModel().create(
1578 1578 text=text,
1579 1579 repo=self.db_repo.repo_id,
1580 1580 user=self._rhodecode_user.user_id,
1581 1581 pull_request=pull_request,
1582 1582 f_path=self.request.POST.get('f_path'),
1583 1583 line_no=self.request.POST.get('line'),
1584 1584 status_change=(ChangesetStatus.get_status_lbl(status)
1585 1585 if status and allowed_to_change_status else None),
1586 1586 status_change_type=(status
1587 1587 if status and allowed_to_change_status else None),
1588 1588 comment_type=comment_type,
1589 1589 resolves_comment_id=resolves_comment_id,
1590 1590 auth_user=self._rhodecode_user
1591 1591 )
1592 1592 is_inline = bool(comment.f_path and comment.line_no)
1593 1593
1594 1594 if allowed_to_change_status:
1595 1595 # calculate old status before we change it
1596 1596 old_calculated_status = pull_request.calculated_review_status()
1597 1597
1598 1598 # get status if set !
1599 1599 if status:
1600 1600 ChangesetStatusModel().set_status(
1601 1601 self.db_repo.repo_id,
1602 1602 status,
1603 1603 self._rhodecode_user.user_id,
1604 1604 comment,
1605 1605 pull_request=pull_request
1606 1606 )
1607 1607
1608 1608 Session().flush()
1609 1609 # this is somehow required to get access to some relationship
1610 1610 # loaded on comment
1611 1611 Session().refresh(comment)
1612 1612
1613 1613 PullRequestModel().trigger_pull_request_hook(
1614 1614 pull_request, self._rhodecode_user, 'comment',
1615 1615 data={'comment': comment})
1616 1616
1617 1617 # we now calculate the status of pull request, and based on that
1618 1618 # calculation we set the commits status
1619 1619 calculated_status = pull_request.calculated_review_status()
1620 1620 if old_calculated_status != calculated_status:
1621 1621 PullRequestModel().trigger_pull_request_hook(
1622 1622 pull_request, self._rhodecode_user, 'review_status_change',
1623 1623 data={'status': calculated_status})
1624 1624
1625 1625 Session().commit()
1626 1626
1627 1627 data = {
1628 1628 'target_id': h.safeid(h.safe_unicode(
1629 1629 self.request.POST.get('f_path'))),
1630 1630 }
1631 1631 if comment:
1632 1632 c.co = comment
1633 1633 c.at_version_num = None
1634 1634 rendered_comment = render(
1635 1635 'rhodecode:templates/changeset/changeset_comment_block.mako',
1636 1636 self._get_template_context(c), self.request)
1637 1637
1638 1638 data.update(comment.get_dict())
1639 1639 data.update({'rendered_text': rendered_comment})
1640 1640
1641 1641 comment_broadcast_channel = channelstream.comment_channel(
1642 1642 self.db_repo_name, pull_request_obj=pull_request)
1643 1643
1644 1644 comment_data = data
1645 1645 comment_type = 'inline' if is_inline else 'general'
1646 1646 channelstream.comment_channelstream_push(
1647 1647 self.request, comment_broadcast_channel, self._rhodecode_user,
1648 1648 _('posted a new {} comment').format(comment_type),
1649 1649 comment_data=comment_data)
1650 1650
1651 1651 return data
1652 1652
1653 1653 @LoginRequired()
1654 1654 @NotAnonymous()
1655 1655 @HasRepoPermissionAnyDecorator(
1656 1656 'repository.read', 'repository.write', 'repository.admin')
1657 1657 @CSRFRequired()
1658 1658 @view_config(
1659 1659 route_name='pullrequest_comment_delete', request_method='POST',
1660 1660 renderer='json_ext')
1661 1661 def pull_request_comment_delete(self):
1662 1662 pull_request = PullRequest.get_or_404(
1663 1663 self.request.matchdict['pull_request_id'])
1664 1664
1665 1665 comment = ChangesetComment.get_or_404(
1666 1666 self.request.matchdict['comment_id'])
1667 1667 comment_id = comment.comment_id
1668 1668
1669 1669 if comment.immutable:
1670 1670 # don't allow deleting comments that are immutable
1671 1671 raise HTTPForbidden()
1672 1672
1673 1673 if pull_request.is_closed():
1674 1674 log.debug('comment: forbidden because pull request is closed')
1675 1675 raise HTTPForbidden()
1676 1676
1677 1677 if not comment:
1678 1678 log.debug('Comment with id:%s not found, skipping', comment_id)
1679 1679 # comment already deleted in another call probably
1680 1680 return True
1681 1681
1682 1682 if comment.pull_request.is_closed():
1683 1683 # don't allow deleting comments on closed pull request
1684 1684 raise HTTPForbidden()
1685 1685
1686 1686 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1687 1687 super_admin = h.HasPermissionAny('hg.admin')()
1688 1688 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1689 1689 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1690 1690 comment_repo_admin = is_repo_admin and is_repo_comment
1691 1691
1692 1692 if super_admin or comment_owner or comment_repo_admin:
1693 1693 old_calculated_status = comment.pull_request.calculated_review_status()
1694 1694 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1695 1695 Session().commit()
1696 1696 calculated_status = comment.pull_request.calculated_review_status()
1697 1697 if old_calculated_status != calculated_status:
1698 1698 PullRequestModel().trigger_pull_request_hook(
1699 1699 comment.pull_request, self._rhodecode_user, 'review_status_change',
1700 1700 data={'status': calculated_status})
1701 1701 return True
1702 1702 else:
1703 1703 log.warning('No permissions for user %s to delete comment_id: %s',
1704 1704 self._rhodecode_db_user, comment_id)
1705 1705 raise HTTPNotFound()
1706 1706
1707 1707 @LoginRequired()
1708 1708 @NotAnonymous()
1709 1709 @HasRepoPermissionAnyDecorator(
1710 1710 'repository.read', 'repository.write', 'repository.admin')
1711 1711 @CSRFRequired()
1712 1712 @view_config(
1713 1713 route_name='pullrequest_comment_edit', request_method='POST',
1714 1714 renderer='json_ext')
1715 1715 def pull_request_comment_edit(self):
1716 1716 self.load_default_context()
1717 1717
1718 1718 pull_request = PullRequest.get_or_404(
1719 1719 self.request.matchdict['pull_request_id']
1720 1720 )
1721 1721 comment = ChangesetComment.get_or_404(
1722 1722 self.request.matchdict['comment_id']
1723 1723 )
1724 1724 comment_id = comment.comment_id
1725 1725
1726 1726 if comment.immutable:
1727 1727 # don't allow deleting comments that are immutable
1728 1728 raise HTTPForbidden()
1729 1729
1730 1730 if pull_request.is_closed():
1731 1731 log.debug('comment: forbidden because pull request is closed')
1732 1732 raise HTTPForbidden()
1733 1733
1734 1734 if not comment:
1735 1735 log.debug('Comment with id:%s not found, skipping', comment_id)
1736 1736 # comment already deleted in another call probably
1737 1737 return True
1738 1738
1739 1739 if comment.pull_request.is_closed():
1740 1740 # don't allow deleting comments on closed pull request
1741 1741 raise HTTPForbidden()
1742 1742
1743 1743 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1744 1744 super_admin = h.HasPermissionAny('hg.admin')()
1745 1745 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1746 1746 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1747 1747 comment_repo_admin = is_repo_admin and is_repo_comment
1748 1748
1749 1749 if super_admin or comment_owner or comment_repo_admin:
1750 1750 text = self.request.POST.get('text')
1751 1751 version = self.request.POST.get('version')
1752 1752 if text == comment.text:
1753 1753 log.warning(
1754 1754 'Comment(PR): '
1755 1755 'Trying to create new version '
1756 1756 'with the same comment body {}'.format(
1757 1757 comment_id,
1758 1758 )
1759 1759 )
1760 1760 raise HTTPNotFound()
1761 1761
1762 1762 if version.isdigit():
1763 1763 version = int(version)
1764 1764 else:
1765 1765 log.warning(
1766 1766 'Comment(PR): Wrong version type {} {} '
1767 1767 'for comment {}'.format(
1768 1768 version,
1769 1769 type(version),
1770 1770 comment_id,
1771 1771 )
1772 1772 )
1773 1773 raise HTTPNotFound()
1774 1774
1775 1775 try:
1776 1776 comment_history = CommentsModel().edit(
1777 1777 comment_id=comment_id,
1778 1778 text=text,
1779 1779 auth_user=self._rhodecode_user,
1780 1780 version=version,
1781 1781 )
1782 1782 except CommentVersionMismatch:
1783 1783 raise HTTPConflict()
1784 1784
1785 1785 if not comment_history:
1786 1786 raise HTTPNotFound()
1787 1787
1788 1788 Session().commit()
1789 1789
1790 1790 PullRequestModel().trigger_pull_request_hook(
1791 1791 pull_request, self._rhodecode_user, 'comment_edit',
1792 1792 data={'comment': comment})
1793 1793
1794 1794 return {
1795 1795 'comment_history_id': comment_history.comment_history_id,
1796 1796 'comment_id': comment.comment_id,
1797 1797 'comment_version': comment_history.version,
1798 1798 'comment_author_username': comment_history.author.username,
1799 1799 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
1800 1800 'comment_created_on': h.age_component(comment_history.created_on,
1801 1801 time_is_local=True),
1802 1802 }
1803 1803 else:
1804 1804 log.warning('No permissions for user %s to edit comment_id: %s',
1805 1805 self._rhodecode_db_user, comment_id)
1806 1806 raise HTTPNotFound()
@@ -1,818 +1,821 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 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 comments model for RhodeCode
23 23 """
24 24 import datetime
25 25
26 26 import logging
27 27 import traceback
28 28 import collections
29 29
30 30 from pyramid.threadlocal import get_current_registry, get_current_request
31 31 from sqlalchemy.sql.expression import null
32 32 from sqlalchemy.sql.functions import coalesce
33 33
34 34 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
35 35 from rhodecode.lib import audit_logger
36 36 from rhodecode.lib.exceptions import CommentVersionMismatch
37 37 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int
38 38 from rhodecode.model import BaseModel
39 39 from rhodecode.model.db import (
40 40 ChangesetComment,
41 41 User,
42 42 Notification,
43 43 PullRequest,
44 44 AttributeDict,
45 45 ChangesetCommentHistory,
46 46 )
47 47 from rhodecode.model.notification import NotificationModel
48 48 from rhodecode.model.meta import Session
49 49 from rhodecode.model.settings import VcsSettingsModel
50 50 from rhodecode.model.notification import EmailNotificationModel
51 51 from rhodecode.model.validation_schema.schemas import comment_schema
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class CommentsModel(BaseModel):
58 58
59 59 cls = ChangesetComment
60 60
61 61 DIFF_CONTEXT_BEFORE = 3
62 62 DIFF_CONTEXT_AFTER = 3
63 63
64 64 def __get_commit_comment(self, changeset_comment):
65 65 return self._get_instance(ChangesetComment, changeset_comment)
66 66
67 67 def __get_pull_request(self, pull_request):
68 68 return self._get_instance(PullRequest, pull_request)
69 69
70 70 def _extract_mentions(self, s):
71 71 user_objects = []
72 72 for username in extract_mentioned_users(s):
73 73 user_obj = User.get_by_username(username, case_insensitive=True)
74 74 if user_obj:
75 75 user_objects.append(user_obj)
76 76 return user_objects
77 77
78 78 def _get_renderer(self, global_renderer='rst', request=None):
79 79 request = request or get_current_request()
80 80
81 81 try:
82 82 global_renderer = request.call_context.visual.default_renderer
83 83 except AttributeError:
84 84 log.debug("Renderer not set, falling back "
85 85 "to default renderer '%s'", global_renderer)
86 86 except Exception:
87 87 log.error(traceback.format_exc())
88 88 return global_renderer
89 89
90 90 def aggregate_comments(self, comments, versions, show_version, inline=False):
91 91 # group by versions, and count until, and display objects
92 92
93 93 comment_groups = collections.defaultdict(list)
94 94 [comment_groups[_co.pull_request_version_id].append(_co) for _co in comments]
95 95
96 96 def yield_comments(pos):
97 97 for co in comment_groups[pos]:
98 98 yield co
99 99
100 100 comment_versions = collections.defaultdict(
101 101 lambda: collections.defaultdict(list))
102 102 prev_prvid = -1
103 103 # fake last entry with None, to aggregate on "latest" version which
104 104 # doesn't have an pull_request_version_id
105 105 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
106 106 prvid = ver.pull_request_version_id
107 107 if prev_prvid == -1:
108 108 prev_prvid = prvid
109 109
110 110 for co in yield_comments(prvid):
111 111 comment_versions[prvid]['at'].append(co)
112 112
113 113 # save until
114 114 current = comment_versions[prvid]['at']
115 115 prev_until = comment_versions[prev_prvid]['until']
116 116 cur_until = prev_until + current
117 117 comment_versions[prvid]['until'].extend(cur_until)
118 118
119 119 # save outdated
120 120 if inline:
121 121 outdated = [x for x in cur_until
122 122 if x.outdated_at_version(show_version)]
123 123 else:
124 124 outdated = [x for x in cur_until
125 125 if x.older_than_version(show_version)]
126 126 display = [x for x in cur_until if x not in outdated]
127 127
128 128 comment_versions[prvid]['outdated'] = outdated
129 129 comment_versions[prvid]['display'] = display
130 130
131 131 prev_prvid = prvid
132 132
133 133 return comment_versions
134 134
135 135 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
136 136 qry = Session().query(ChangesetComment) \
137 137 .filter(ChangesetComment.repo == repo)
138 138
139 139 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
140 140 qry = qry.filter(ChangesetComment.comment_type == comment_type)
141 141
142 142 if user:
143 143 user = self._get_user(user)
144 144 if user:
145 145 qry = qry.filter(ChangesetComment.user_id == user.user_id)
146 146
147 147 if commit_id:
148 148 qry = qry.filter(ChangesetComment.revision == commit_id)
149 149
150 150 qry = qry.order_by(ChangesetComment.created_on)
151 151 return qry.all()
152 152
153 153 def get_repository_unresolved_todos(self, repo):
154 154 todos = Session().query(ChangesetComment) \
155 155 .filter(ChangesetComment.repo == repo) \
156 156 .filter(ChangesetComment.resolved_by == None) \
157 157 .filter(ChangesetComment.comment_type
158 158 == ChangesetComment.COMMENT_TYPE_TODO)
159 159 todos = todos.all()
160 160
161 161 return todos
162 162
163 163 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
164 164
165 165 todos = Session().query(ChangesetComment) \
166 166 .filter(ChangesetComment.pull_request == pull_request) \
167 167 .filter(ChangesetComment.resolved_by == None) \
168 168 .filter(ChangesetComment.comment_type
169 169 == ChangesetComment.COMMENT_TYPE_TODO)
170 170
171 171 if not show_outdated:
172 172 todos = todos.filter(
173 173 coalesce(ChangesetComment.display_state, '') !=
174 174 ChangesetComment.COMMENT_OUTDATED)
175 175
176 176 todos = todos.all()
177 177
178 178 return todos
179 179
180 180 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
181 181
182 182 todos = Session().query(ChangesetComment) \
183 183 .filter(ChangesetComment.pull_request == pull_request) \
184 184 .filter(ChangesetComment.resolved_by != None) \
185 185 .filter(ChangesetComment.comment_type
186 186 == ChangesetComment.COMMENT_TYPE_TODO)
187 187
188 188 if not show_outdated:
189 189 todos = todos.filter(
190 190 coalesce(ChangesetComment.display_state, '') !=
191 191 ChangesetComment.COMMENT_OUTDATED)
192 192
193 193 todos = todos.all()
194 194
195 195 return todos
196 196
197 197 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
198 198
199 199 todos = Session().query(ChangesetComment) \
200 200 .filter(ChangesetComment.revision == commit_id) \
201 201 .filter(ChangesetComment.resolved_by == None) \
202 202 .filter(ChangesetComment.comment_type
203 203 == ChangesetComment.COMMENT_TYPE_TODO)
204 204
205 205 if not show_outdated:
206 206 todos = todos.filter(
207 207 coalesce(ChangesetComment.display_state, '') !=
208 208 ChangesetComment.COMMENT_OUTDATED)
209 209
210 210 todos = todos.all()
211 211
212 212 return todos
213 213
214 214 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
215 215
216 216 todos = Session().query(ChangesetComment) \
217 217 .filter(ChangesetComment.revision == commit_id) \
218 218 .filter(ChangesetComment.resolved_by != None) \
219 219 .filter(ChangesetComment.comment_type
220 220 == ChangesetComment.COMMENT_TYPE_TODO)
221 221
222 222 if not show_outdated:
223 223 todos = todos.filter(
224 224 coalesce(ChangesetComment.display_state, '') !=
225 225 ChangesetComment.COMMENT_OUTDATED)
226 226
227 227 todos = todos.all()
228 228
229 229 return todos
230 230
231 231 def get_commit_inline_comments(self, commit_id):
232 232 inline_comments = Session().query(ChangesetComment) \
233 233 .filter(ChangesetComment.line_no != None) \
234 234 .filter(ChangesetComment.f_path != None) \
235 235 .filter(ChangesetComment.revision == commit_id)
236 236 inline_comments = inline_comments.all()
237 237 return inline_comments
238 238
239 239 def _log_audit_action(self, action, action_data, auth_user, comment):
240 240 audit_logger.store(
241 241 action=action,
242 242 action_data=action_data,
243 243 user=auth_user,
244 244 repo=comment.repo)
245 245
246 246 def create(self, text, repo, user, commit_id=None, pull_request=None,
247 247 f_path=None, line_no=None, status_change=None,
248 248 status_change_type=None, comment_type=None,
249 249 resolves_comment_id=None, closing_pr=False, send_email=True,
250 250 renderer=None, auth_user=None, extra_recipients=None):
251 251 """
252 252 Creates new comment for commit or pull request.
253 253 IF status_change is not none this comment is associated with a
254 254 status change of commit or commit associated with pull request
255 255
256 256 :param text:
257 257 :param repo:
258 258 :param user:
259 259 :param commit_id:
260 260 :param pull_request:
261 261 :param f_path:
262 262 :param line_no:
263 263 :param status_change: Label for status change
264 264 :param comment_type: Type of comment
265 265 :param resolves_comment_id: id of comment which this one will resolve
266 266 :param status_change_type: type of status change
267 267 :param closing_pr:
268 268 :param send_email:
269 269 :param renderer: pick renderer for this comment
270 270 :param auth_user: current authenticated user calling this method
271 271 :param extra_recipients: list of extra users to be added to recipients
272 272 """
273 273
274 274 if not text:
275 275 log.warning('Missing text for comment, skipping...')
276 276 return
277 277 request = get_current_request()
278 278 _ = request.translate
279 279
280 280 if not renderer:
281 281 renderer = self._get_renderer(request=request)
282 282
283 283 repo = self._get_repo(repo)
284 284 user = self._get_user(user)
285 285 auth_user = auth_user or user
286 286
287 287 schema = comment_schema.CommentSchema()
288 288 validated_kwargs = schema.deserialize(dict(
289 289 comment_body=text,
290 290 comment_type=comment_type,
291 291 comment_file=f_path,
292 292 comment_line=line_no,
293 293 renderer_type=renderer,
294 294 status_change=status_change_type,
295 295 resolves_comment_id=resolves_comment_id,
296 296 repo=repo.repo_id,
297 297 user=user.user_id,
298 298 ))
299 299
300 300 comment = ChangesetComment()
301 301 comment.renderer = validated_kwargs['renderer_type']
302 302 comment.text = validated_kwargs['comment_body']
303 303 comment.f_path = validated_kwargs['comment_file']
304 304 comment.line_no = validated_kwargs['comment_line']
305 305 comment.comment_type = validated_kwargs['comment_type']
306 306
307 307 comment.repo = repo
308 308 comment.author = user
309 309 resolved_comment = self.__get_commit_comment(
310 310 validated_kwargs['resolves_comment_id'])
311 311 # check if the comment actually belongs to this PR
312 312 if resolved_comment and resolved_comment.pull_request and \
313 313 resolved_comment.pull_request != pull_request:
314 314 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 315 resolved_comment)
316 316 # comment not bound to this pull request, forbid
317 317 resolved_comment = None
318 318
319 319 elif resolved_comment and resolved_comment.repo and \
320 320 resolved_comment.repo != repo:
321 321 log.warning('Comment tried to resolved unrelated todo comment: %s',
322 322 resolved_comment)
323 323 # comment not bound to this repo, forbid
324 324 resolved_comment = None
325 325
326 326 comment.resolved_comment = resolved_comment
327 327
328 328 pull_request_id = pull_request
329 329
330 330 commit_obj = None
331 331 pull_request_obj = None
332 332
333 333 if commit_id:
334 334 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
335 335 # do a lookup, so we don't pass something bad here
336 336 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
337 337 comment.revision = commit_obj.raw_id
338 338
339 339 elif pull_request_id:
340 340 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
341 341 pull_request_obj = self.__get_pull_request(pull_request_id)
342 342 comment.pull_request = pull_request_obj
343 343 else:
344 344 raise Exception('Please specify commit or pull_request_id')
345 345
346 346 Session().add(comment)
347 347 Session().flush()
348 348 kwargs = {
349 349 'user': user,
350 350 'renderer_type': renderer,
351 351 'repo_name': repo.repo_name,
352 352 'status_change': status_change,
353 353 'status_change_type': status_change_type,
354 354 'comment_body': text,
355 355 'comment_file': f_path,
356 356 'comment_line': line_no,
357 357 'comment_type': comment_type or 'note',
358 358 'comment_id': comment.comment_id
359 359 }
360 360
361 361 if commit_obj:
362 362 recipients = ChangesetComment.get_users(
363 363 revision=commit_obj.raw_id)
364 364 # add commit author if it's in RhodeCode system
365 365 cs_author = User.get_from_cs_author(commit_obj.author)
366 366 if not cs_author:
367 367 # use repo owner if we cannot extract the author correctly
368 368 cs_author = repo.user
369 369 recipients += [cs_author]
370 370
371 371 commit_comment_url = self.get_url(comment, request=request)
372 372 commit_comment_reply_url = self.get_url(
373 373 comment, request=request,
374 374 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
375 375
376 376 target_repo_url = h.link_to(
377 377 repo.repo_name,
378 378 h.route_url('repo_summary', repo_name=repo.repo_name))
379 379
380 380 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
381 381 commit_id=commit_id)
382 382
383 383 # commit specifics
384 384 kwargs.update({
385 385 'commit': commit_obj,
386 386 'commit_message': commit_obj.message,
387 387 'commit_target_repo_url': target_repo_url,
388 388 'commit_comment_url': commit_comment_url,
389 389 'commit_comment_reply_url': commit_comment_reply_url,
390 390 'commit_url': commit_url,
391 391 'thread_ids': [commit_url, commit_comment_url],
392 392 })
393 393
394 394 elif pull_request_obj:
395 395 # get the current participants of this pull request
396 396 recipients = ChangesetComment.get_users(
397 397 pull_request_id=pull_request_obj.pull_request_id)
398 398 # add pull request author
399 399 recipients += [pull_request_obj.author]
400 400
401 401 # add the reviewers to notification
402 402 recipients += [x.user for x in pull_request_obj.reviewers]
403 403
404 404 pr_target_repo = pull_request_obj.target_repo
405 405 pr_source_repo = pull_request_obj.source_repo
406 406
407 407 pr_comment_url = self.get_url(comment, request=request)
408 408 pr_comment_reply_url = self.get_url(
409 409 comment, request=request,
410 410 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
411 411
412 412 pr_url = h.route_url(
413 413 'pullrequest_show',
414 414 repo_name=pr_target_repo.repo_name,
415 415 pull_request_id=pull_request_obj.pull_request_id, )
416 416
417 417 # set some variables for email notification
418 418 pr_target_repo_url = h.route_url(
419 419 'repo_summary', repo_name=pr_target_repo.repo_name)
420 420
421 421 pr_source_repo_url = h.route_url(
422 422 'repo_summary', repo_name=pr_source_repo.repo_name)
423 423
424 424 # pull request specifics
425 425 kwargs.update({
426 426 'pull_request': pull_request_obj,
427 427 'pr_id': pull_request_obj.pull_request_id,
428 428 'pull_request_url': pr_url,
429 429 'pull_request_target_repo': pr_target_repo,
430 430 'pull_request_target_repo_url': pr_target_repo_url,
431 431 'pull_request_source_repo': pr_source_repo,
432 432 'pull_request_source_repo_url': pr_source_repo_url,
433 433 'pr_comment_url': pr_comment_url,
434 434 'pr_comment_reply_url': pr_comment_reply_url,
435 435 'pr_closing': closing_pr,
436 436 'thread_ids': [pr_url, pr_comment_url],
437 437 })
438 438
439 439 if send_email:
440 440 recipients += [self._get_user(u) for u in (extra_recipients or [])]
441 441 # pre-generate the subject for notification itself
442 442 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
443 443 notification_type, **kwargs)
444 444
445 445 mention_recipients = set(
446 446 self._extract_mentions(text)).difference(recipients)
447 447
448 448 # create notification objects, and emails
449 449 NotificationModel().create(
450 450 created_by=user,
451 451 notification_subject=subject,
452 452 notification_body=body_plaintext,
453 453 notification_type=notification_type,
454 454 recipients=recipients,
455 455 mention_recipients=mention_recipients,
456 456 email_kwargs=kwargs,
457 457 )
458 458
459 459 Session().flush()
460 460 if comment.pull_request:
461 461 action = 'repo.pull_request.comment.create'
462 462 else:
463 463 action = 'repo.commit.comment.create'
464 464
465 465 comment_data = comment.get_api_data()
466 466
467 467 self._log_audit_action(
468 468 action, {'data': comment_data}, auth_user, comment)
469 469
470 470 return comment
471 471
472 472 def edit(self, comment_id, text, auth_user, version):
473 473 """
474 474 Change existing comment for commit or pull request.
475 475
476 476 :param comment_id:
477 477 :param text:
478 478 :param auth_user: current authenticated user calling this method
479 479 :param version: last comment version
480 480 """
481 481 if not text:
482 482 log.warning('Missing text for comment, skipping...')
483 483 return
484 484
485 485 comment = ChangesetComment.get(comment_id)
486 486 old_comment_text = comment.text
487 487 comment.text = text
488 488 comment.modified_at = datetime.datetime.now()
489 489 version = safe_int(version)
490 490
491 491 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
492 492 # would return 3 here
493 493 comment_version = ChangesetCommentHistory.get_version(comment_id)
494 494
495 495 if isinstance(version, (int, long)) and (comment_version - version) != 1:
496 496 log.warning(
497 497 'Version mismatch comment_version {} submitted {}, skipping'.format(
498 498 comment_version-1, # -1 since note above
499 499 version
500 500 )
501 501 )
502 502 raise CommentVersionMismatch()
503 503
504 504 comment_history = ChangesetCommentHistory()
505 505 comment_history.comment_id = comment_id
506 506 comment_history.version = comment_version
507 507 comment_history.created_by_user_id = auth_user.user_id
508 508 comment_history.text = old_comment_text
509 509 # TODO add email notification
510 510 Session().add(comment_history)
511 511 Session().add(comment)
512 512 Session().flush()
513 513
514 514 if comment.pull_request:
515 515 action = 'repo.pull_request.comment.edit'
516 516 else:
517 517 action = 'repo.commit.comment.edit'
518 518
519 519 comment_data = comment.get_api_data()
520 520 comment_data['old_comment_text'] = old_comment_text
521 521 self._log_audit_action(
522 522 action, {'data': comment_data}, auth_user, comment)
523 523
524 524 return comment_history
525 525
526 526 def delete(self, comment, auth_user):
527 527 """
528 528 Deletes given comment
529 529 """
530 530 comment = self.__get_commit_comment(comment)
531 531 old_data = comment.get_api_data()
532 532 Session().delete(comment)
533 533
534 534 if comment.pull_request:
535 535 action = 'repo.pull_request.comment.delete'
536 536 else:
537 537 action = 'repo.commit.comment.delete'
538 538
539 539 self._log_audit_action(
540 540 action, {'old_data': old_data}, auth_user, comment)
541 541
542 542 return comment
543 543
544 def get_all_comments(self, repo_id, revision=None, pull_request=None):
544 def get_all_comments(self, repo_id, revision=None, pull_request=None, count_only=False):
545 545 q = ChangesetComment.query()\
546 546 .filter(ChangesetComment.repo_id == repo_id)
547 547 if revision:
548 548 q = q.filter(ChangesetComment.revision == revision)
549 549 elif pull_request:
550 550 pull_request = self.__get_pull_request(pull_request)
551 q = q.filter(ChangesetComment.pull_request == pull_request)
551 q = q.filter(ChangesetComment.pull_request_id == pull_request.pull_request_id)
552 552 else:
553 553 raise Exception('Please specify commit or pull_request')
554 554 q = q.order_by(ChangesetComment.created_on)
555 if count_only:
556 return q.count()
557
555 558 return q.all()
556 559
557 560 def get_url(self, comment, request=None, permalink=False, anchor=None):
558 561 if not request:
559 562 request = get_current_request()
560 563
561 564 comment = self.__get_commit_comment(comment)
562 565 if anchor is None:
563 566 anchor = 'comment-{}'.format(comment.comment_id)
564 567
565 568 if comment.pull_request:
566 569 pull_request = comment.pull_request
567 570 if permalink:
568 571 return request.route_url(
569 572 'pull_requests_global',
570 573 pull_request_id=pull_request.pull_request_id,
571 574 _anchor=anchor)
572 575 else:
573 576 return request.route_url(
574 577 'pullrequest_show',
575 578 repo_name=safe_str(pull_request.target_repo.repo_name),
576 579 pull_request_id=pull_request.pull_request_id,
577 580 _anchor=anchor)
578 581
579 582 else:
580 583 repo = comment.repo
581 584 commit_id = comment.revision
582 585
583 586 if permalink:
584 587 return request.route_url(
585 588 'repo_commit', repo_name=safe_str(repo.repo_id),
586 589 commit_id=commit_id,
587 590 _anchor=anchor)
588 591
589 592 else:
590 593 return request.route_url(
591 594 'repo_commit', repo_name=safe_str(repo.repo_name),
592 595 commit_id=commit_id,
593 596 _anchor=anchor)
594 597
595 598 def get_comments(self, repo_id, revision=None, pull_request=None):
596 599 """
597 600 Gets main comments based on revision or pull_request_id
598 601
599 602 :param repo_id:
600 603 :param revision:
601 604 :param pull_request:
602 605 """
603 606
604 607 q = ChangesetComment.query()\
605 608 .filter(ChangesetComment.repo_id == repo_id)\
606 609 .filter(ChangesetComment.line_no == None)\
607 610 .filter(ChangesetComment.f_path == None)
608 611 if revision:
609 612 q = q.filter(ChangesetComment.revision == revision)
610 613 elif pull_request:
611 614 pull_request = self.__get_pull_request(pull_request)
612 615 q = q.filter(ChangesetComment.pull_request == pull_request)
613 616 else:
614 617 raise Exception('Please specify commit or pull_request')
615 618 q = q.order_by(ChangesetComment.created_on)
616 619 return q.all()
617 620
618 621 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
619 622 q = self._get_inline_comments_query(repo_id, revision, pull_request)
620 623 return self._group_comments_by_path_and_line_number(q)
621 624
622 625 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
623 626 version=None):
624 627 inline_comms = []
625 628 for fname, per_line_comments in inline_comments.iteritems():
626 629 for lno, comments in per_line_comments.iteritems():
627 630 for comm in comments:
628 631 if not comm.outdated_at_version(version) and skip_outdated:
629 632 inline_comms.append(comm)
630 633
631 634 return inline_comms
632 635
633 636 def get_outdated_comments(self, repo_id, pull_request):
634 637 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
635 638 # of a pull request.
636 639 q = self._all_inline_comments_of_pull_request(pull_request)
637 640 q = q.filter(
638 641 ChangesetComment.display_state ==
639 642 ChangesetComment.COMMENT_OUTDATED
640 643 ).order_by(ChangesetComment.comment_id.asc())
641 644
642 645 return self._group_comments_by_path_and_line_number(q)
643 646
644 647 def _get_inline_comments_query(self, repo_id, revision, pull_request):
645 648 # TODO: johbo: Split this into two methods: One for PR and one for
646 649 # commit.
647 650 if revision:
648 651 q = Session().query(ChangesetComment).filter(
649 652 ChangesetComment.repo_id == repo_id,
650 653 ChangesetComment.line_no != null(),
651 654 ChangesetComment.f_path != null(),
652 655 ChangesetComment.revision == revision)
653 656
654 657 elif pull_request:
655 658 pull_request = self.__get_pull_request(pull_request)
656 659 if not CommentsModel.use_outdated_comments(pull_request):
657 660 q = self._visible_inline_comments_of_pull_request(pull_request)
658 661 else:
659 662 q = self._all_inline_comments_of_pull_request(pull_request)
660 663
661 664 else:
662 665 raise Exception('Please specify commit or pull_request_id')
663 666 q = q.order_by(ChangesetComment.comment_id.asc())
664 667 return q
665 668
666 669 def _group_comments_by_path_and_line_number(self, q):
667 670 comments = q.all()
668 671 paths = collections.defaultdict(lambda: collections.defaultdict(list))
669 672 for co in comments:
670 673 paths[co.f_path][co.line_no].append(co)
671 674 return paths
672 675
673 676 @classmethod
674 677 def needed_extra_diff_context(cls):
675 678 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
676 679
677 680 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
678 681 if not CommentsModel.use_outdated_comments(pull_request):
679 682 return
680 683
681 684 comments = self._visible_inline_comments_of_pull_request(pull_request)
682 685 comments_to_outdate = comments.all()
683 686
684 687 for comment in comments_to_outdate:
685 688 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
686 689
687 690 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
688 691 diff_line = _parse_comment_line_number(comment.line_no)
689 692
690 693 try:
691 694 old_context = old_diff_proc.get_context_of_line(
692 695 path=comment.f_path, diff_line=diff_line)
693 696 new_context = new_diff_proc.get_context_of_line(
694 697 path=comment.f_path, diff_line=diff_line)
695 698 except (diffs.LineNotInDiffException,
696 699 diffs.FileNotInDiffException):
697 700 comment.display_state = ChangesetComment.COMMENT_OUTDATED
698 701 return
699 702
700 703 if old_context == new_context:
701 704 return
702 705
703 706 if self._should_relocate_diff_line(diff_line):
704 707 new_diff_lines = new_diff_proc.find_context(
705 708 path=comment.f_path, context=old_context,
706 709 offset=self.DIFF_CONTEXT_BEFORE)
707 710 if not new_diff_lines:
708 711 comment.display_state = ChangesetComment.COMMENT_OUTDATED
709 712 else:
710 713 new_diff_line = self._choose_closest_diff_line(
711 714 diff_line, new_diff_lines)
712 715 comment.line_no = _diff_to_comment_line_number(new_diff_line)
713 716 else:
714 717 comment.display_state = ChangesetComment.COMMENT_OUTDATED
715 718
716 719 def _should_relocate_diff_line(self, diff_line):
717 720 """
718 721 Checks if relocation shall be tried for the given `diff_line`.
719 722
720 723 If a comment points into the first lines, then we can have a situation
721 724 that after an update another line has been added on top. In this case
722 725 we would find the context still and move the comment around. This
723 726 would be wrong.
724 727 """
725 728 should_relocate = (
726 729 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
727 730 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
728 731 return should_relocate
729 732
730 733 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
731 734 candidate = new_diff_lines[0]
732 735 best_delta = _diff_line_delta(diff_line, candidate)
733 736 for new_diff_line in new_diff_lines[1:]:
734 737 delta = _diff_line_delta(diff_line, new_diff_line)
735 738 if delta < best_delta:
736 739 candidate = new_diff_line
737 740 best_delta = delta
738 741 return candidate
739 742
740 743 def _visible_inline_comments_of_pull_request(self, pull_request):
741 744 comments = self._all_inline_comments_of_pull_request(pull_request)
742 745 comments = comments.filter(
743 746 coalesce(ChangesetComment.display_state, '') !=
744 747 ChangesetComment.COMMENT_OUTDATED)
745 748 return comments
746 749
747 750 def _all_inline_comments_of_pull_request(self, pull_request):
748 751 comments = Session().query(ChangesetComment)\
749 752 .filter(ChangesetComment.line_no != None)\
750 753 .filter(ChangesetComment.f_path != None)\
751 754 .filter(ChangesetComment.pull_request == pull_request)
752 755 return comments
753 756
754 757 def _all_general_comments_of_pull_request(self, pull_request):
755 758 comments = Session().query(ChangesetComment)\
756 759 .filter(ChangesetComment.line_no == None)\
757 760 .filter(ChangesetComment.f_path == None)\
758 761 .filter(ChangesetComment.pull_request == pull_request)
759 762
760 763 return comments
761 764
762 765 @staticmethod
763 766 def use_outdated_comments(pull_request):
764 767 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
765 768 settings = settings_model.get_general_settings()
766 769 return settings.get('rhodecode_use_outdated_comments', False)
767 770
768 771 def trigger_commit_comment_hook(self, repo, user, action, data=None):
769 772 repo = self._get_repo(repo)
770 773 target_scm = repo.scm_instance()
771 774 if action == 'create':
772 775 trigger_hook = hooks_utils.trigger_comment_commit_hooks
773 776 elif action == 'edit':
774 777 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
775 778 else:
776 779 return
777 780
778 781 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
779 782 repo, action, trigger_hook)
780 783 trigger_hook(
781 784 username=user.username,
782 785 repo_name=repo.repo_name,
783 786 repo_type=target_scm.alias,
784 787 repo=repo,
785 788 data=data)
786 789
787 790
788 791 def _parse_comment_line_number(line_no):
789 792 """
790 793 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
791 794 """
792 795 old_line = None
793 796 new_line = None
794 797 if line_no.startswith('o'):
795 798 old_line = int(line_no[1:])
796 799 elif line_no.startswith('n'):
797 800 new_line = int(line_no[1:])
798 801 else:
799 802 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
800 803 return diffs.DiffLineNumber(old_line, new_line)
801 804
802 805
803 806 def _diff_to_comment_line_number(diff_line):
804 807 if diff_line.new is not None:
805 808 return u'n{}'.format(diff_line.new)
806 809 elif diff_line.old is not None:
807 810 return u'o{}'.format(diff_line.old)
808 811 return u''
809 812
810 813
811 814 def _diff_line_delta(a, b):
812 815 if None not in (a.new, b.new):
813 816 return abs(a.new - b.new)
814 817 elif None not in (a.old, b.old):
815 818 return abs(a.old - b.old)
816 819 else:
817 820 raise ValueError(
818 821 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now