##// END OF EJS Templates
emails: set References header for threading in mail user agents even with different subjects...
marcink -
r4447:ae62a3cc default
parent child Browse files
Show More
@@ -1,784 +1,783 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 import logging
23 23 import collections
24 24
25 25 import datetime
26 26 import formencode
27 27 import formencode.htmlfill
28 28
29 29 import rhodecode
30 30 from pyramid.view import view_config
31 31 from pyramid.httpexceptions import HTTPFound, HTTPNotFound
32 32 from pyramid.renderers import render
33 33 from pyramid.response import Response
34 34
35 35 from rhodecode.apps._base import BaseAppView
36 36 from rhodecode.apps._base.navigation import navigation_list
37 37 from rhodecode.apps.svn_support.config_keys import generate_config
38 38 from rhodecode.lib import helpers as h
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasPermissionAllDecorator, CSRFRequired)
41 41 from rhodecode.lib.celerylib import tasks, run_task
42 42 from rhodecode.lib.utils import repo2db_mapper
43 43 from rhodecode.lib.utils2 import str2bool, safe_unicode, AttributeDict
44 44 from rhodecode.lib.index import searcher_from_config
45 45
46 46 from rhodecode.model.db import RhodeCodeUi, Repository
47 47 from rhodecode.model.forms import (ApplicationSettingsForm,
48 48 ApplicationUiSettingsForm, ApplicationVisualisationForm,
49 49 LabsSettingsForm, IssueTrackerPatternsForm)
50 50 from rhodecode.model.permission import PermissionModel
51 51 from rhodecode.model.repo_group import RepoGroupModel
52 52
53 53 from rhodecode.model.scm import ScmModel
54 54 from rhodecode.model.notification import EmailNotificationModel
55 55 from rhodecode.model.meta import Session
56 56 from rhodecode.model.settings import (
57 57 IssueTrackerSettingsModel, VcsSettingsModel, SettingNotFound,
58 58 SettingsModel)
59 59
60 60
61 61 log = logging.getLogger(__name__)
62 62
63 63
64 64 class AdminSettingsView(BaseAppView):
65 65
66 66 def load_default_context(self):
67 67 c = self._get_local_tmpl_context()
68 68 c.labs_active = str2bool(
69 69 rhodecode.CONFIG.get('labs_settings_active', 'true'))
70 70 c.navlist = navigation_list(self.request)
71 71
72 72 return c
73 73
74 74 @classmethod
75 75 def _get_ui_settings(cls):
76 76 ret = RhodeCodeUi.query().all()
77 77
78 78 if not ret:
79 79 raise Exception('Could not get application ui settings !')
80 80 settings = {}
81 81 for each in ret:
82 82 k = each.ui_key
83 83 v = each.ui_value
84 84 if k == '/':
85 85 k = 'root_path'
86 86
87 87 if k in ['push_ssl', 'publish', 'enabled']:
88 88 v = str2bool(v)
89 89
90 90 if k.find('.') != -1:
91 91 k = k.replace('.', '_')
92 92
93 93 if each.ui_section in ['hooks', 'extensions']:
94 94 v = each.ui_active
95 95
96 96 settings[each.ui_section + '_' + k] = v
97 97 return settings
98 98
99 99 @classmethod
100 100 def _form_defaults(cls):
101 101 defaults = SettingsModel().get_all_settings()
102 102 defaults.update(cls._get_ui_settings())
103 103
104 104 defaults.update({
105 105 'new_svn_branch': '',
106 106 'new_svn_tag': '',
107 107 })
108 108 return defaults
109 109
110 110 @LoginRequired()
111 111 @HasPermissionAllDecorator('hg.admin')
112 112 @view_config(
113 113 route_name='admin_settings_vcs', request_method='GET',
114 114 renderer='rhodecode:templates/admin/settings/settings.mako')
115 115 def settings_vcs(self):
116 116 c = self.load_default_context()
117 117 c.active = 'vcs'
118 118 model = VcsSettingsModel()
119 119 c.svn_branch_patterns = model.get_global_svn_branch_patterns()
120 120 c.svn_tag_patterns = model.get_global_svn_tag_patterns()
121 121
122 122 settings = self.request.registry.settings
123 123 c.svn_proxy_generate_config = settings[generate_config]
124 124
125 125 defaults = self._form_defaults()
126 126
127 127 model.create_largeobjects_dirs_if_needed(defaults['paths_root_path'])
128 128
129 129 data = render('rhodecode:templates/admin/settings/settings.mako',
130 130 self._get_template_context(c), self.request)
131 131 html = formencode.htmlfill.render(
132 132 data,
133 133 defaults=defaults,
134 134 encoding="UTF-8",
135 135 force_defaults=False
136 136 )
137 137 return Response(html)
138 138
139 139 @LoginRequired()
140 140 @HasPermissionAllDecorator('hg.admin')
141 141 @CSRFRequired()
142 142 @view_config(
143 143 route_name='admin_settings_vcs_update', request_method='POST',
144 144 renderer='rhodecode:templates/admin/settings/settings.mako')
145 145 def settings_vcs_update(self):
146 146 _ = self.request.translate
147 147 c = self.load_default_context()
148 148 c.active = 'vcs'
149 149
150 150 model = VcsSettingsModel()
151 151 c.svn_branch_patterns = model.get_global_svn_branch_patterns()
152 152 c.svn_tag_patterns = model.get_global_svn_tag_patterns()
153 153
154 154 settings = self.request.registry.settings
155 155 c.svn_proxy_generate_config = settings[generate_config]
156 156
157 157 application_form = ApplicationUiSettingsForm(self.request.translate)()
158 158
159 159 try:
160 160 form_result = application_form.to_python(dict(self.request.POST))
161 161 except formencode.Invalid as errors:
162 162 h.flash(
163 163 _("Some form inputs contain invalid data."),
164 164 category='error')
165 165 data = render('rhodecode:templates/admin/settings/settings.mako',
166 166 self._get_template_context(c), self.request)
167 167 html = formencode.htmlfill.render(
168 168 data,
169 169 defaults=errors.value,
170 170 errors=errors.error_dict or {},
171 171 prefix_error=False,
172 172 encoding="UTF-8",
173 173 force_defaults=False
174 174 )
175 175 return Response(html)
176 176
177 177 try:
178 178 if c.visual.allow_repo_location_change:
179 179 model.update_global_path_setting(form_result['paths_root_path'])
180 180
181 181 model.update_global_ssl_setting(form_result['web_push_ssl'])
182 182 model.update_global_hook_settings(form_result)
183 183
184 184 model.create_or_update_global_svn_settings(form_result)
185 185 model.create_or_update_global_hg_settings(form_result)
186 186 model.create_or_update_global_git_settings(form_result)
187 187 model.create_or_update_global_pr_settings(form_result)
188 188 except Exception:
189 189 log.exception("Exception while updating settings")
190 190 h.flash(_('Error occurred during updating '
191 191 'application settings'), category='error')
192 192 else:
193 193 Session().commit()
194 194 h.flash(_('Updated VCS settings'), category='success')
195 195 raise HTTPFound(h.route_path('admin_settings_vcs'))
196 196
197 197 data = render('rhodecode:templates/admin/settings/settings.mako',
198 198 self._get_template_context(c), self.request)
199 199 html = formencode.htmlfill.render(
200 200 data,
201 201 defaults=self._form_defaults(),
202 202 encoding="UTF-8",
203 203 force_defaults=False
204 204 )
205 205 return Response(html)
206 206
207 207 @LoginRequired()
208 208 @HasPermissionAllDecorator('hg.admin')
209 209 @CSRFRequired()
210 210 @view_config(
211 211 route_name='admin_settings_vcs_svn_pattern_delete', request_method='POST',
212 212 renderer='json_ext', xhr=True)
213 213 def settings_vcs_delete_svn_pattern(self):
214 214 delete_pattern_id = self.request.POST.get('delete_svn_pattern')
215 215 model = VcsSettingsModel()
216 216 try:
217 217 model.delete_global_svn_pattern(delete_pattern_id)
218 218 except SettingNotFound:
219 219 log.exception(
220 220 'Failed to delete svn_pattern with id %s', delete_pattern_id)
221 221 raise HTTPNotFound()
222 222
223 223 Session().commit()
224 224 return True
225 225
226 226 @LoginRequired()
227 227 @HasPermissionAllDecorator('hg.admin')
228 228 @view_config(
229 229 route_name='admin_settings_mapping', request_method='GET',
230 230 renderer='rhodecode:templates/admin/settings/settings.mako')
231 231 def settings_mapping(self):
232 232 c = self.load_default_context()
233 233 c.active = 'mapping'
234 234
235 235 data = render('rhodecode:templates/admin/settings/settings.mako',
236 236 self._get_template_context(c), self.request)
237 237 html = formencode.htmlfill.render(
238 238 data,
239 239 defaults=self._form_defaults(),
240 240 encoding="UTF-8",
241 241 force_defaults=False
242 242 )
243 243 return Response(html)
244 244
245 245 @LoginRequired()
246 246 @HasPermissionAllDecorator('hg.admin')
247 247 @CSRFRequired()
248 248 @view_config(
249 249 route_name='admin_settings_mapping_update', request_method='POST',
250 250 renderer='rhodecode:templates/admin/settings/settings.mako')
251 251 def settings_mapping_update(self):
252 252 _ = self.request.translate
253 253 c = self.load_default_context()
254 254 c.active = 'mapping'
255 255 rm_obsolete = self.request.POST.get('destroy', False)
256 256 invalidate_cache = self.request.POST.get('invalidate', False)
257 257 log.debug('rescanning repo location with destroy obsolete=%s', rm_obsolete)
258 258
259 259 if invalidate_cache:
260 260 log.debug('invalidating all repositories cache')
261 261 for repo in Repository.get_all():
262 262 ScmModel().mark_for_invalidation(repo.repo_name, delete=True)
263 263
264 264 filesystem_repos = ScmModel().repo_scan()
265 265 added, removed = repo2db_mapper(filesystem_repos, rm_obsolete)
266 266 PermissionModel().trigger_permission_flush()
267 267
268 268 _repr = lambda l: ', '.join(map(safe_unicode, l)) or '-'
269 269 h.flash(_('Repositories successfully '
270 270 'rescanned added: %s ; removed: %s') %
271 271 (_repr(added), _repr(removed)),
272 272 category='success')
273 273 raise HTTPFound(h.route_path('admin_settings_mapping'))
274 274
275 275 @LoginRequired()
276 276 @HasPermissionAllDecorator('hg.admin')
277 277 @view_config(
278 278 route_name='admin_settings', request_method='GET',
279 279 renderer='rhodecode:templates/admin/settings/settings.mako')
280 280 @view_config(
281 281 route_name='admin_settings_global', request_method='GET',
282 282 renderer='rhodecode:templates/admin/settings/settings.mako')
283 283 def settings_global(self):
284 284 c = self.load_default_context()
285 285 c.active = 'global'
286 286 c.personal_repo_group_default_pattern = RepoGroupModel()\
287 287 .get_personal_group_name_pattern()
288 288
289 289 data = render('rhodecode:templates/admin/settings/settings.mako',
290 290 self._get_template_context(c), self.request)
291 291 html = formencode.htmlfill.render(
292 292 data,
293 293 defaults=self._form_defaults(),
294 294 encoding="UTF-8",
295 295 force_defaults=False
296 296 )
297 297 return Response(html)
298 298
299 299 @LoginRequired()
300 300 @HasPermissionAllDecorator('hg.admin')
301 301 @CSRFRequired()
302 302 @view_config(
303 303 route_name='admin_settings_update', request_method='POST',
304 304 renderer='rhodecode:templates/admin/settings/settings.mako')
305 305 @view_config(
306 306 route_name='admin_settings_global_update', request_method='POST',
307 307 renderer='rhodecode:templates/admin/settings/settings.mako')
308 308 def settings_global_update(self):
309 309 _ = self.request.translate
310 310 c = self.load_default_context()
311 311 c.active = 'global'
312 312 c.personal_repo_group_default_pattern = RepoGroupModel()\
313 313 .get_personal_group_name_pattern()
314 314 application_form = ApplicationSettingsForm(self.request.translate)()
315 315 try:
316 316 form_result = application_form.to_python(dict(self.request.POST))
317 317 except formencode.Invalid as errors:
318 318 h.flash(
319 319 _("Some form inputs contain invalid data."),
320 320 category='error')
321 321 data = render('rhodecode:templates/admin/settings/settings.mako',
322 322 self._get_template_context(c), self.request)
323 323 html = formencode.htmlfill.render(
324 324 data,
325 325 defaults=errors.value,
326 326 errors=errors.error_dict or {},
327 327 prefix_error=False,
328 328 encoding="UTF-8",
329 329 force_defaults=False
330 330 )
331 331 return Response(html)
332 332
333 333 settings = [
334 334 ('title', 'rhodecode_title', 'unicode'),
335 335 ('realm', 'rhodecode_realm', 'unicode'),
336 336 ('pre_code', 'rhodecode_pre_code', 'unicode'),
337 337 ('post_code', 'rhodecode_post_code', 'unicode'),
338 338 ('captcha_public_key', 'rhodecode_captcha_public_key', 'unicode'),
339 339 ('captcha_private_key', 'rhodecode_captcha_private_key', 'unicode'),
340 340 ('create_personal_repo_group', 'rhodecode_create_personal_repo_group', 'bool'),
341 341 ('personal_repo_group_pattern', 'rhodecode_personal_repo_group_pattern', 'unicode'),
342 342 ]
343 343 try:
344 344 for setting, form_key, type_ in settings:
345 345 sett = SettingsModel().create_or_update_setting(
346 346 setting, form_result[form_key], type_)
347 347 Session().add(sett)
348 348
349 349 Session().commit()
350 350 SettingsModel().invalidate_settings_cache()
351 351 h.flash(_('Updated application settings'), category='success')
352 352 except Exception:
353 353 log.exception("Exception while updating application settings")
354 354 h.flash(
355 355 _('Error occurred during updating application settings'),
356 356 category='error')
357 357
358 358 raise HTTPFound(h.route_path('admin_settings_global'))
359 359
360 360 @LoginRequired()
361 361 @HasPermissionAllDecorator('hg.admin')
362 362 @view_config(
363 363 route_name='admin_settings_visual', request_method='GET',
364 364 renderer='rhodecode:templates/admin/settings/settings.mako')
365 365 def settings_visual(self):
366 366 c = self.load_default_context()
367 367 c.active = 'visual'
368 368
369 369 data = render('rhodecode:templates/admin/settings/settings.mako',
370 370 self._get_template_context(c), self.request)
371 371 html = formencode.htmlfill.render(
372 372 data,
373 373 defaults=self._form_defaults(),
374 374 encoding="UTF-8",
375 375 force_defaults=False
376 376 )
377 377 return Response(html)
378 378
379 379 @LoginRequired()
380 380 @HasPermissionAllDecorator('hg.admin')
381 381 @CSRFRequired()
382 382 @view_config(
383 383 route_name='admin_settings_visual_update', request_method='POST',
384 384 renderer='rhodecode:templates/admin/settings/settings.mako')
385 385 def settings_visual_update(self):
386 386 _ = self.request.translate
387 387 c = self.load_default_context()
388 388 c.active = 'visual'
389 389 application_form = ApplicationVisualisationForm(self.request.translate)()
390 390 try:
391 391 form_result = application_form.to_python(dict(self.request.POST))
392 392 except formencode.Invalid as errors:
393 393 h.flash(
394 394 _("Some form inputs contain invalid data."),
395 395 category='error')
396 396 data = render('rhodecode:templates/admin/settings/settings.mako',
397 397 self._get_template_context(c), self.request)
398 398 html = formencode.htmlfill.render(
399 399 data,
400 400 defaults=errors.value,
401 401 errors=errors.error_dict or {},
402 402 prefix_error=False,
403 403 encoding="UTF-8",
404 404 force_defaults=False
405 405 )
406 406 return Response(html)
407 407
408 408 try:
409 409 settings = [
410 410 ('show_public_icon', 'rhodecode_show_public_icon', 'bool'),
411 411 ('show_private_icon', 'rhodecode_show_private_icon', 'bool'),
412 412 ('stylify_metatags', 'rhodecode_stylify_metatags', 'bool'),
413 413 ('repository_fields', 'rhodecode_repository_fields', 'bool'),
414 414 ('dashboard_items', 'rhodecode_dashboard_items', 'int'),
415 415 ('admin_grid_items', 'rhodecode_admin_grid_items', 'int'),
416 416 ('show_version', 'rhodecode_show_version', 'bool'),
417 417 ('use_gravatar', 'rhodecode_use_gravatar', 'bool'),
418 418 ('markup_renderer', 'rhodecode_markup_renderer', 'unicode'),
419 419 ('gravatar_url', 'rhodecode_gravatar_url', 'unicode'),
420 420 ('clone_uri_tmpl', 'rhodecode_clone_uri_tmpl', 'unicode'),
421 421 ('clone_uri_ssh_tmpl', 'rhodecode_clone_uri_ssh_tmpl', 'unicode'),
422 422 ('support_url', 'rhodecode_support_url', 'unicode'),
423 423 ('show_revision_number', 'rhodecode_show_revision_number', 'bool'),
424 424 ('show_sha_length', 'rhodecode_show_sha_length', 'int'),
425 425 ]
426 426 for setting, form_key, type_ in settings:
427 427 sett = SettingsModel().create_or_update_setting(
428 428 setting, form_result[form_key], type_)
429 429 Session().add(sett)
430 430
431 431 Session().commit()
432 432 SettingsModel().invalidate_settings_cache()
433 433 h.flash(_('Updated visualisation settings'), category='success')
434 434 except Exception:
435 435 log.exception("Exception updating visualization settings")
436 436 h.flash(_('Error occurred during updating '
437 437 'visualisation settings'),
438 438 category='error')
439 439
440 440 raise HTTPFound(h.route_path('admin_settings_visual'))
441 441
442 442 @LoginRequired()
443 443 @HasPermissionAllDecorator('hg.admin')
444 444 @view_config(
445 445 route_name='admin_settings_issuetracker', request_method='GET',
446 446 renderer='rhodecode:templates/admin/settings/settings.mako')
447 447 def settings_issuetracker(self):
448 448 c = self.load_default_context()
449 449 c.active = 'issuetracker'
450 450 defaults = c.rc_config
451 451
452 452 entry_key = 'rhodecode_issuetracker_pat_'
453 453
454 454 c.issuetracker_entries = {}
455 455 for k, v in defaults.items():
456 456 if k.startswith(entry_key):
457 457 uid = k[len(entry_key):]
458 458 c.issuetracker_entries[uid] = None
459 459
460 460 for uid in c.issuetracker_entries:
461 461 c.issuetracker_entries[uid] = AttributeDict({
462 462 'pat': defaults.get('rhodecode_issuetracker_pat_' + uid),
463 463 'url': defaults.get('rhodecode_issuetracker_url_' + uid),
464 464 'pref': defaults.get('rhodecode_issuetracker_pref_' + uid),
465 465 'desc': defaults.get('rhodecode_issuetracker_desc_' + uid),
466 466 })
467 467
468 468 return self._get_template_context(c)
469 469
470 470 @LoginRequired()
471 471 @HasPermissionAllDecorator('hg.admin')
472 472 @CSRFRequired()
473 473 @view_config(
474 474 route_name='admin_settings_issuetracker_test', request_method='POST',
475 475 renderer='string', xhr=True)
476 476 def settings_issuetracker_test(self):
477 477 return h.urlify_commit_message(
478 478 self.request.POST.get('test_text', ''),
479 479 'repo_group/test_repo1')
480 480
481 481 @LoginRequired()
482 482 @HasPermissionAllDecorator('hg.admin')
483 483 @CSRFRequired()
484 484 @view_config(
485 485 route_name='admin_settings_issuetracker_update', request_method='POST',
486 486 renderer='rhodecode:templates/admin/settings/settings.mako')
487 487 def settings_issuetracker_update(self):
488 488 _ = self.request.translate
489 489 self.load_default_context()
490 490 settings_model = IssueTrackerSettingsModel()
491 491
492 492 try:
493 493 form = IssueTrackerPatternsForm(self.request.translate)()
494 494 data = form.to_python(self.request.POST)
495 495 except formencode.Invalid as errors:
496 496 log.exception('Failed to add new pattern')
497 497 error = errors
498 498 h.flash(_('Invalid issue tracker pattern: {}'.format(error)),
499 499 category='error')
500 500 raise HTTPFound(h.route_path('admin_settings_issuetracker'))
501 501
502 502 if data:
503 503 for uid in data.get('delete_patterns', []):
504 504 settings_model.delete_entries(uid)
505 505
506 506 for pattern in data.get('patterns', []):
507 507 for setting, value, type_ in pattern:
508 508 sett = settings_model.create_or_update_setting(
509 509 setting, value, type_)
510 510 Session().add(sett)
511 511
512 512 Session().commit()
513 513
514 514 SettingsModel().invalidate_settings_cache()
515 515 h.flash(_('Updated issue tracker entries'), category='success')
516 516 raise HTTPFound(h.route_path('admin_settings_issuetracker'))
517 517
518 518 @LoginRequired()
519 519 @HasPermissionAllDecorator('hg.admin')
520 520 @CSRFRequired()
521 521 @view_config(
522 522 route_name='admin_settings_issuetracker_delete', request_method='POST',
523 523 renderer='json_ext', xhr=True)
524 524 def settings_issuetracker_delete(self):
525 525 _ = self.request.translate
526 526 self.load_default_context()
527 527 uid = self.request.POST.get('uid')
528 528 try:
529 529 IssueTrackerSettingsModel().delete_entries(uid)
530 530 except Exception:
531 531 log.exception('Failed to delete issue tracker setting %s', uid)
532 532 raise HTTPNotFound()
533 533
534 534 SettingsModel().invalidate_settings_cache()
535 535 h.flash(_('Removed issue tracker entry.'), category='success')
536 536
537 537 return {'deleted': uid}
538 538
539 539 @LoginRequired()
540 540 @HasPermissionAllDecorator('hg.admin')
541 541 @view_config(
542 542 route_name='admin_settings_email', request_method='GET',
543 543 renderer='rhodecode:templates/admin/settings/settings.mako')
544 544 def settings_email(self):
545 545 c = self.load_default_context()
546 546 c.active = 'email'
547 547 c.rhodecode_ini = rhodecode.CONFIG
548 548
549 549 data = render('rhodecode:templates/admin/settings/settings.mako',
550 550 self._get_template_context(c), self.request)
551 551 html = formencode.htmlfill.render(
552 552 data,
553 553 defaults=self._form_defaults(),
554 554 encoding="UTF-8",
555 555 force_defaults=False
556 556 )
557 557 return Response(html)
558 558
559 559 @LoginRequired()
560 560 @HasPermissionAllDecorator('hg.admin')
561 561 @CSRFRequired()
562 562 @view_config(
563 563 route_name='admin_settings_email_update', request_method='POST',
564 564 renderer='rhodecode:templates/admin/settings/settings.mako')
565 565 def settings_email_update(self):
566 566 _ = self.request.translate
567 567 c = self.load_default_context()
568 568 c.active = 'email'
569 569
570 570 test_email = self.request.POST.get('test_email')
571 571
572 572 if not test_email:
573 573 h.flash(_('Please enter email address'), category='error')
574 574 raise HTTPFound(h.route_path('admin_settings_email'))
575 575
576 576 email_kwargs = {
577 577 'date': datetime.datetime.now(),
578 578 'user': self._rhodecode_db_user
579 579 }
580 580
581 (subject, headers, email_body,
582 email_body_plaintext) = EmailNotificationModel().render_email(
581 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
583 582 EmailNotificationModel.TYPE_EMAIL_TEST, **email_kwargs)
584 583
585 584 recipients = [test_email] if test_email else None
586 585
587 586 run_task(tasks.send_email, recipients, subject,
588 587 email_body_plaintext, email_body)
589 588
590 589 h.flash(_('Send email task created'), category='success')
591 590 raise HTTPFound(h.route_path('admin_settings_email'))
592 591
593 592 @LoginRequired()
594 593 @HasPermissionAllDecorator('hg.admin')
595 594 @view_config(
596 595 route_name='admin_settings_hooks', request_method='GET',
597 596 renderer='rhodecode:templates/admin/settings/settings.mako')
598 597 def settings_hooks(self):
599 598 c = self.load_default_context()
600 599 c.active = 'hooks'
601 600
602 601 model = SettingsModel()
603 602 c.hooks = model.get_builtin_hooks()
604 603 c.custom_hooks = model.get_custom_hooks()
605 604
606 605 data = render('rhodecode:templates/admin/settings/settings.mako',
607 606 self._get_template_context(c), self.request)
608 607 html = formencode.htmlfill.render(
609 608 data,
610 609 defaults=self._form_defaults(),
611 610 encoding="UTF-8",
612 611 force_defaults=False
613 612 )
614 613 return Response(html)
615 614
616 615 @LoginRequired()
617 616 @HasPermissionAllDecorator('hg.admin')
618 617 @CSRFRequired()
619 618 @view_config(
620 619 route_name='admin_settings_hooks_update', request_method='POST',
621 620 renderer='rhodecode:templates/admin/settings/settings.mako')
622 621 @view_config(
623 622 route_name='admin_settings_hooks_delete', request_method='POST',
624 623 renderer='rhodecode:templates/admin/settings/settings.mako')
625 624 def settings_hooks_update(self):
626 625 _ = self.request.translate
627 626 c = self.load_default_context()
628 627 c.active = 'hooks'
629 628 if c.visual.allow_custom_hooks_settings:
630 629 ui_key = self.request.POST.get('new_hook_ui_key')
631 630 ui_value = self.request.POST.get('new_hook_ui_value')
632 631
633 632 hook_id = self.request.POST.get('hook_id')
634 633 new_hook = False
635 634
636 635 model = SettingsModel()
637 636 try:
638 637 if ui_value and ui_key:
639 638 model.create_or_update_hook(ui_key, ui_value)
640 639 h.flash(_('Added new hook'), category='success')
641 640 new_hook = True
642 641 elif hook_id:
643 642 RhodeCodeUi.delete(hook_id)
644 643 Session().commit()
645 644
646 645 # check for edits
647 646 update = False
648 647 _d = self.request.POST.dict_of_lists()
649 648 for k, v in zip(_d.get('hook_ui_key', []),
650 649 _d.get('hook_ui_value_new', [])):
651 650 model.create_or_update_hook(k, v)
652 651 update = True
653 652
654 653 if update and not new_hook:
655 654 h.flash(_('Updated hooks'), category='success')
656 655 Session().commit()
657 656 except Exception:
658 657 log.exception("Exception during hook creation")
659 658 h.flash(_('Error occurred during hook creation'),
660 659 category='error')
661 660
662 661 raise HTTPFound(h.route_path('admin_settings_hooks'))
663 662
664 663 @LoginRequired()
665 664 @HasPermissionAllDecorator('hg.admin')
666 665 @view_config(
667 666 route_name='admin_settings_search', request_method='GET',
668 667 renderer='rhodecode:templates/admin/settings/settings.mako')
669 668 def settings_search(self):
670 669 c = self.load_default_context()
671 670 c.active = 'search'
672 671
673 672 c.searcher = searcher_from_config(self.request.registry.settings)
674 673 c.statistics = c.searcher.statistics(self.request.translate)
675 674
676 675 return self._get_template_context(c)
677 676
678 677 @LoginRequired()
679 678 @HasPermissionAllDecorator('hg.admin')
680 679 @view_config(
681 680 route_name='admin_settings_automation', request_method='GET',
682 681 renderer='rhodecode:templates/admin/settings/settings.mako')
683 682 def settings_automation(self):
684 683 c = self.load_default_context()
685 684 c.active = 'automation'
686 685
687 686 return self._get_template_context(c)
688 687
689 688 @LoginRequired()
690 689 @HasPermissionAllDecorator('hg.admin')
691 690 @view_config(
692 691 route_name='admin_settings_labs', request_method='GET',
693 692 renderer='rhodecode:templates/admin/settings/settings.mako')
694 693 def settings_labs(self):
695 694 c = self.load_default_context()
696 695 if not c.labs_active:
697 696 raise HTTPFound(h.route_path('admin_settings'))
698 697
699 698 c.active = 'labs'
700 699 c.lab_settings = _LAB_SETTINGS
701 700
702 701 data = render('rhodecode:templates/admin/settings/settings.mako',
703 702 self._get_template_context(c), self.request)
704 703 html = formencode.htmlfill.render(
705 704 data,
706 705 defaults=self._form_defaults(),
707 706 encoding="UTF-8",
708 707 force_defaults=False
709 708 )
710 709 return Response(html)
711 710
712 711 @LoginRequired()
713 712 @HasPermissionAllDecorator('hg.admin')
714 713 @CSRFRequired()
715 714 @view_config(
716 715 route_name='admin_settings_labs_update', request_method='POST',
717 716 renderer='rhodecode:templates/admin/settings/settings.mako')
718 717 def settings_labs_update(self):
719 718 _ = self.request.translate
720 719 c = self.load_default_context()
721 720 c.active = 'labs'
722 721
723 722 application_form = LabsSettingsForm(self.request.translate)()
724 723 try:
725 724 form_result = application_form.to_python(dict(self.request.POST))
726 725 except formencode.Invalid as errors:
727 726 h.flash(
728 727 _("Some form inputs contain invalid data."),
729 728 category='error')
730 729 data = render('rhodecode:templates/admin/settings/settings.mako',
731 730 self._get_template_context(c), self.request)
732 731 html = formencode.htmlfill.render(
733 732 data,
734 733 defaults=errors.value,
735 734 errors=errors.error_dict or {},
736 735 prefix_error=False,
737 736 encoding="UTF-8",
738 737 force_defaults=False
739 738 )
740 739 return Response(html)
741 740
742 741 try:
743 742 session = Session()
744 743 for setting in _LAB_SETTINGS:
745 744 setting_name = setting.key[len('rhodecode_'):]
746 745 sett = SettingsModel().create_or_update_setting(
747 746 setting_name, form_result[setting.key], setting.type)
748 747 session.add(sett)
749 748
750 749 except Exception:
751 750 log.exception('Exception while updating lab settings')
752 751 h.flash(_('Error occurred during updating labs settings'),
753 752 category='error')
754 753 else:
755 754 Session().commit()
756 755 SettingsModel().invalidate_settings_cache()
757 756 h.flash(_('Updated Labs settings'), category='success')
758 757 raise HTTPFound(h.route_path('admin_settings_labs'))
759 758
760 759 data = render('rhodecode:templates/admin/settings/settings.mako',
761 760 self._get_template_context(c), self.request)
762 761 html = formencode.htmlfill.render(
763 762 data,
764 763 defaults=self._form_defaults(),
765 764 encoding="UTF-8",
766 765 force_defaults=False
767 766 )
768 767 return Response(html)
769 768
770 769
771 770 # :param key: name of the setting including the 'rhodecode_' prefix
772 771 # :param type: the RhodeCodeSetting type to use.
773 772 # :param group: the i18ned group in which we should dispaly this setting
774 773 # :param label: the i18ned label we should display for this setting
775 774 # :param help: the i18ned help we should dispaly for this setting
776 775 LabSetting = collections.namedtuple(
777 776 'LabSetting', ('key', 'type', 'group', 'label', 'help'))
778 777
779 778
780 779 # This list has to be kept in sync with the form
781 780 # rhodecode.model.forms.LabsSettingsForm.
782 781 _LAB_SETTINGS = [
783 782
784 783 ]
@@ -1,419 +1,418 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 os
22 22 import logging
23 23 import datetime
24 24
25 25 from pyramid.view import view_config
26 26 from pyramid.renderers import render_to_response
27 27 from rhodecode.apps._base import BaseAppView
28 28 from rhodecode.lib.celerylib import run_task, tasks
29 29 from rhodecode.lib.utils2 import AttributeDict
30 30 from rhodecode.model.db import User
31 31 from rhodecode.model.notification import EmailNotificationModel
32 32
33 33 log = logging.getLogger(__name__)
34 34
35 35
36 36 class DebugStyleView(BaseAppView):
37 37 def load_default_context(self):
38 38 c = self._get_local_tmpl_context()
39 39
40 40 return c
41 41
42 42 @view_config(
43 43 route_name='debug_style_home', request_method='GET',
44 44 renderer=None)
45 45 def index(self):
46 46 c = self.load_default_context()
47 47 c.active = 'index'
48 48
49 49 return render_to_response(
50 50 'debug_style/index.html', self._get_template_context(c),
51 51 request=self.request)
52 52
53 53 @view_config(
54 54 route_name='debug_style_email', request_method='GET',
55 55 renderer=None)
56 56 @view_config(
57 57 route_name='debug_style_email_plain_rendered', request_method='GET',
58 58 renderer=None)
59 59 def render_email(self):
60 60 c = self.load_default_context()
61 61 email_id = self.request.matchdict['email_id']
62 62 c.active = 'emails'
63 63
64 64 pr = AttributeDict(
65 65 pull_request_id=123,
66 66 title='digital_ocean: fix redis, elastic search start on boot, '
67 67 'fix fd limits on supervisor, set postgres 11 version',
68 68 description='''
69 69 Check if we should use full-topic or mini-topic.
70 70
71 71 - full topic produces some problems with merge states etc
72 72 - server-mini-topic needs probably tweeks.
73 73 ''',
74 74 repo_name='foobar',
75 75 source_ref_parts=AttributeDict(type='branch', name='fix-ticket-2000'),
76 76 target_ref_parts=AttributeDict(type='branch', name='master'),
77 77 )
78 78 target_repo = AttributeDict(repo_name='repo_group/target_repo')
79 79 source_repo = AttributeDict(repo_name='repo_group/source_repo')
80 80 user = User.get_by_username(self.request.GET.get('user')) or self._rhodecode_db_user
81 81 # file/commit changes for PR update
82 82 commit_changes = AttributeDict({
83 83 'added': ['aaaaaaabbbbb', 'cccccccddddddd'],
84 84 'removed': ['eeeeeeeeeee'],
85 85 })
86 86 file_changes = AttributeDict({
87 87 'added': ['a/file1.md', 'file2.py'],
88 88 'modified': ['b/modified_file.rst'],
89 89 'removed': ['.idea'],
90 90 })
91 91
92 92 exc_traceback = {
93 93 'exc_utc_date': '2020-03-26T12:54:50.683281',
94 94 'exc_id': 139638856342656,
95 95 'exc_timestamp': '1585227290.683288',
96 96 'version': 'v1',
97 97 'exc_message': 'Traceback (most recent call last):\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/tweens.py", line 41, in excview_tween\n response = handler(request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/router.py", line 148, in handle_request\n registry, request, context, context_iface, view_name\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/view.py", line 667, in _call_view\n response = view_callable(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/config/views.py", line 188, in attr_view\n return view(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/config/views.py", line 214, in predicate_wrapper\n return view(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/viewderivers.py", line 401, in viewresult_to_response\n result = view(context, request)\n File "/nix/store/s43k2r9rysfbzmsjdqnxgzvvb7zjhkxb-python2.7-pyramid-1.10.4/lib/python2.7/site-packages/pyramid/viewderivers.py", line 132, in _class_view\n response = getattr(inst, attr)()\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/apps/debug_style/views.py", line 355, in render_email\n template_type, **email_kwargs.get(email_id, {}))\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/model/notification.py", line 402, in render_email\n body = email_template.render(None, **_kwargs)\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/lib/partial_renderer.py", line 95, in render\n return self._render_with_exc(tmpl, args, kwargs)\n File "/mnt/hgfs/marcink/workspace/rhodecode-enterprise-ce/rhodecode/lib/partial_renderer.py", line 79, in _render_with_exc\n return render_func.render(*args, **kwargs)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/template.py", line 476, in render\n return runtime._render(self, self.callable_, args, data)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/runtime.py", line 883, in _render\n **_kwargs_for_callable(callable_, data)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/runtime.py", line 920, in _render_context\n _exec_template(inherit, lclcontext, args=args, kwargs=kwargs)\n File "/nix/store/dakh34sxz4yfr435c0cwjz0sd6hnd5g3-python2.7-mako-1.1.0/lib/python2.7/site-packages/mako/runtime.py", line 947, in _exec_template\n callable_(context, *args, **kwargs)\n File "rhodecode_templates_email_templates_base_mako", line 63, in render_body\n File "rhodecode_templates_email_templates_exception_tracker_mako", line 43, in render_body\nAttributeError: \'str\' object has no attribute \'get\'\n',
98 98 'exc_type': 'AttributeError'
99 99 }
100 100 email_kwargs = {
101 101 'test': {},
102 102 'message': {
103 103 'body': 'message body !'
104 104 },
105 105 'email_test': {
106 106 'user': user,
107 107 'date': datetime.datetime.now(),
108 108 },
109 109 'exception': {
110 110 'email_prefix': '[RHODECODE ERROR]',
111 111 'exc_id': exc_traceback['exc_id'],
112 112 'exc_url': 'http://server-url/{}'.format(exc_traceback['exc_id']),
113 113 'exc_type_name': 'NameError',
114 114 'exc_traceback': exc_traceback,
115 115 },
116 116 'password_reset': {
117 117 'password_reset_url': 'http://example.com/reset-rhodecode-password/token',
118 118
119 119 'user': user,
120 120 'date': datetime.datetime.now(),
121 121 'email': 'test@rhodecode.com',
122 122 'first_admin_email': User.get_first_super_admin().email
123 123 },
124 124 'password_reset_confirmation': {
125 125 'new_password': 'new-password-example',
126 126 'user': user,
127 127 'date': datetime.datetime.now(),
128 128 'email': 'test@rhodecode.com',
129 129 'first_admin_email': User.get_first_super_admin().email
130 130 },
131 131 'registration': {
132 132 'user': user,
133 133 'date': datetime.datetime.now(),
134 134 },
135 135
136 136 'pull_request_comment': {
137 137 'user': user,
138 138
139 139 'status_change': None,
140 140 'status_change_type': None,
141 141
142 142 'pull_request': pr,
143 143 'pull_request_commits': [],
144 144
145 145 'pull_request_target_repo': target_repo,
146 146 'pull_request_target_repo_url': 'http://target-repo/url',
147 147
148 148 'pull_request_source_repo': source_repo,
149 149 'pull_request_source_repo_url': 'http://source-repo/url',
150 150
151 151 'pull_request_url': 'http://localhost/pr1',
152 152 'pr_comment_url': 'http://comment-url',
153 153 'pr_comment_reply_url': 'http://comment-url#reply',
154 154
155 155 'comment_file': None,
156 156 'comment_line': None,
157 157 'comment_type': 'note',
158 158 'comment_body': 'This is my comment body. *I like !*',
159 159 'comment_id': 2048,
160 160 'renderer_type': 'markdown',
161 161 'mention': True,
162 162
163 163 },
164 164 'pull_request_comment+status': {
165 165 'user': user,
166 166
167 167 'status_change': 'approved',
168 168 'status_change_type': 'approved',
169 169
170 170 'pull_request': pr,
171 171 'pull_request_commits': [],
172 172
173 173 'pull_request_target_repo': target_repo,
174 174 'pull_request_target_repo_url': 'http://target-repo/url',
175 175
176 176 'pull_request_source_repo': source_repo,
177 177 'pull_request_source_repo_url': 'http://source-repo/url',
178 178
179 179 'pull_request_url': 'http://localhost/pr1',
180 180 'pr_comment_url': 'http://comment-url',
181 181 'pr_comment_reply_url': 'http://comment-url#reply',
182 182
183 183 'comment_type': 'todo',
184 184 'comment_file': None,
185 185 'comment_line': None,
186 186 'comment_body': '''
187 187 I think something like this would be better
188 188
189 189 ```py
190 190 // markdown renderer
191 191
192 192 def db():
193 193 global connection
194 194 return connection
195 195
196 196 ```
197 197
198 198 ''',
199 199 'comment_id': 2048,
200 200 'renderer_type': 'markdown',
201 201 'mention': True,
202 202
203 203 },
204 204 'pull_request_comment+file': {
205 205 'user': user,
206 206
207 207 'status_change': None,
208 208 'status_change_type': None,
209 209
210 210 'pull_request': pr,
211 211 'pull_request_commits': [],
212 212
213 213 'pull_request_target_repo': target_repo,
214 214 'pull_request_target_repo_url': 'http://target-repo/url',
215 215
216 216 'pull_request_source_repo': source_repo,
217 217 'pull_request_source_repo_url': 'http://source-repo/url',
218 218
219 219 'pull_request_url': 'http://localhost/pr1',
220 220
221 221 'pr_comment_url': 'http://comment-url',
222 222 'pr_comment_reply_url': 'http://comment-url#reply',
223 223
224 224 'comment_file': 'rhodecode/model/get_flow_commits',
225 225 'comment_line': 'o1210',
226 226 'comment_type': 'todo',
227 227 'comment_body': '''
228 228 I like this !
229 229
230 230 But please check this code
231 231
232 232 .. code-block:: javascript
233 233
234 234 // THIS IS RST CODE
235 235
236 236 this.createResolutionComment = function(commentId) {
237 237 // hide the trigger text
238 238 $('#resolve-comment-{0}'.format(commentId)).hide();
239 239
240 240 var comment = $('#comment-'+commentId);
241 241 var commentData = comment.data();
242 242 if (commentData.commentInline) {
243 243 this.createComment(comment, commentId)
244 244 } else {
245 245 Rhodecode.comments.createGeneralComment('general', "$placeholder", commentId)
246 246 }
247 247
248 248 return false;
249 249 };
250 250
251 251 This should work better !
252 252 ''',
253 253 'comment_id': 2048,
254 254 'renderer_type': 'rst',
255 255 'mention': True,
256 256
257 257 },
258 258
259 259 'pull_request_update': {
260 260 'updating_user': user,
261 261
262 262 'status_change': None,
263 263 'status_change_type': None,
264 264
265 265 'pull_request': pr,
266 266 'pull_request_commits': [],
267 267
268 268 'pull_request_target_repo': target_repo,
269 269 'pull_request_target_repo_url': 'http://target-repo/url',
270 270
271 271 'pull_request_source_repo': source_repo,
272 272 'pull_request_source_repo_url': 'http://source-repo/url',
273 273
274 274 'pull_request_url': 'http://localhost/pr1',
275 275
276 276 # update comment links
277 277 'pr_comment_url': 'http://comment-url',
278 278 'pr_comment_reply_url': 'http://comment-url#reply',
279 279 'ancestor_commit_id': 'f39bd443',
280 280 'added_commits': commit_changes.added,
281 281 'removed_commits': commit_changes.removed,
282 282 'changed_files': (file_changes.added + file_changes.modified + file_changes.removed),
283 283 'added_files': file_changes.added,
284 284 'modified_files': file_changes.modified,
285 285 'removed_files': file_changes.removed,
286 286 },
287 287
288 288 'cs_comment': {
289 289 'user': user,
290 290 'commit': AttributeDict(idx=123, raw_id='a'*40, message='Commit message'),
291 291 'status_change': None,
292 292 'status_change_type': None,
293 293
294 294 'commit_target_repo_url': 'http://foo.example.com/#comment1',
295 295 'repo_name': 'test-repo',
296 296 'comment_type': 'note',
297 297 'comment_file': None,
298 298 'comment_line': None,
299 299 'commit_comment_url': 'http://comment-url',
300 300 'commit_comment_reply_url': 'http://comment-url#reply',
301 301 'comment_body': 'This is my comment body. *I like !*',
302 302 'comment_id': 2048,
303 303 'renderer_type': 'markdown',
304 304 'mention': True,
305 305 },
306 306 'cs_comment+status': {
307 307 'user': user,
308 308 'commit': AttributeDict(idx=123, raw_id='a' * 40, message='Commit message'),
309 309 'status_change': 'approved',
310 310 'status_change_type': 'approved',
311 311
312 312 'commit_target_repo_url': 'http://foo.example.com/#comment1',
313 313 'repo_name': 'test-repo',
314 314 'comment_type': 'note',
315 315 'comment_file': None,
316 316 'comment_line': None,
317 317 'commit_comment_url': 'http://comment-url',
318 318 'commit_comment_reply_url': 'http://comment-url#reply',
319 319 'comment_body': '''
320 320 Hello **world**
321 321
322 322 This is a multiline comment :)
323 323
324 324 - list
325 325 - list2
326 326 ''',
327 327 'comment_id': 2048,
328 328 'renderer_type': 'markdown',
329 329 'mention': True,
330 330 },
331 331 'cs_comment+file': {
332 332 'user': user,
333 333 'commit': AttributeDict(idx=123, raw_id='a' * 40, message='Commit message'),
334 334 'status_change': None,
335 335 'status_change_type': None,
336 336
337 337 'commit_target_repo_url': 'http://foo.example.com/#comment1',
338 338 'repo_name': 'test-repo',
339 339
340 340 'comment_type': 'note',
341 341 'comment_file': 'test-file.py',
342 342 'comment_line': 'n100',
343 343
344 344 'commit_comment_url': 'http://comment-url',
345 345 'commit_comment_reply_url': 'http://comment-url#reply',
346 346 'comment_body': 'This is my comment body. *I like !*',
347 347 'comment_id': 2048,
348 348 'renderer_type': 'markdown',
349 349 'mention': True,
350 350 },
351 351
352 352 'pull_request': {
353 353 'user': user,
354 354 'pull_request': pr,
355 355 'pull_request_commits': [
356 356 ('472d1df03bf7206e278fcedc6ac92b46b01c4e21', '''\
357 357 my-account: moved email closer to profile as it's similar data just moved outside.
358 358 '''),
359 359 ('cbfa3061b6de2696c7161ed15ba5c6a0045f90a7', '''\
360 360 users: description edit fixes
361 361
362 362 - tests
363 363 - added metatags info
364 364 '''),
365 365 ],
366 366
367 367 'pull_request_target_repo': target_repo,
368 368 'pull_request_target_repo_url': 'http://target-repo/url',
369 369
370 370 'pull_request_source_repo': source_repo,
371 371 'pull_request_source_repo_url': 'http://source-repo/url',
372 372
373 373 'pull_request_url': 'http://code.rhodecode.com/_pull-request/123',
374 374 }
375 375
376 376 }
377 377
378 378 template_type = email_id.split('+')[0]
379 (c.subject, c.headers, c.email_body,
380 c.email_body_plaintext) = EmailNotificationModel().render_email(
379 (c.subject, c.email_body, c.email_body_plaintext) = EmailNotificationModel().render_email(
381 380 template_type, **email_kwargs.get(email_id, {}))
382 381
383 382 test_email = self.request.GET.get('email')
384 383 if test_email:
385 384 recipients = [test_email]
386 385 run_task(tasks.send_email, recipients, c.subject,
387 386 c.email_body_plaintext, c.email_body)
388 387
389 388 if self.request.matched_route.name == 'debug_style_email_plain_rendered':
390 389 template = 'debug_style/email_plain_rendered.mako'
391 390 else:
392 391 template = 'debug_style/email.mako'
393 392 return render_to_response(
394 393 template, self._get_template_context(c),
395 394 request=self.request)
396 395
397 396 @view_config(
398 397 route_name='debug_style_template', request_method='GET',
399 398 renderer=None)
400 399 def template(self):
401 400 t_path = self.request.matchdict['t_path']
402 401 c = self.load_default_context()
403 402 c.active = os.path.splitext(t_path)[0]
404 403 c.came_from = ''
405 404 c.email_types = {
406 405 'cs_comment+file': {},
407 406 'cs_comment+status': {},
408 407
409 408 'pull_request_comment+file': {},
410 409 'pull_request_comment+status': {},
411 410
412 411 'pull_request_update': {},
413 412 }
414 413 c.email_types.update(EmailNotificationModel.email_types)
415 414
416 415 return render_to_response(
417 416 'debug_style/' + t_path, self._get_template_context(c),
418 417 request=self.request)
419 418
@@ -1,366 +1,379 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-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 RhodeCode task modules, containing all task that suppose to be run
23 23 by celery daemon
24 24 """
25 25
26 26 import os
27 27 import time
28 28
29 29 from pyramid import compat
30 30 from pyramid_mailer.mailer import Mailer
31 31 from pyramid_mailer.message import Message
32 from email.utils import formatdate
32 33
33 34 import rhodecode
34 35 from rhodecode.lib import audit_logger
35 36 from rhodecode.lib.celerylib import get_logger, async_task, RequestContextTask
36 37 from rhodecode.lib import hooks_base
37 38 from rhodecode.lib.utils2 import safe_int, str2bool
38 39 from rhodecode.model.db import (
39 40 Session, IntegrityError, true, Repository, RepoGroup, User)
40 41
41 42
42 43 @async_task(ignore_result=True, base=RequestContextTask)
43 def send_email(recipients, subject, body='', html_body='', email_config=None):
44 def send_email(recipients, subject, body='', html_body='', email_config=None,
45 extra_headers=None):
44 46 """
45 47 Sends an email with defined parameters from the .ini files.
46 48
47 49 :param recipients: list of recipients, it this is empty the defined email
48 50 address from field 'email_to' is used instead
49 51 :param subject: subject of the mail
50 52 :param body: body of the mail
51 53 :param html_body: html version of body
52 54 :param email_config: specify custom configuration for mailer
55 :param extra_headers: specify custom headers
53 56 """
54 57 log = get_logger(send_email)
55 58
56 59 email_config = email_config or rhodecode.CONFIG
57 60
58 61 mail_server = email_config.get('smtp_server') or None
59 62 if mail_server is None:
60 63 log.error("SMTP server information missing. Sending email failed. "
61 64 "Make sure that `smtp_server` variable is configured "
62 65 "inside the .ini file")
63 66 return False
64 67
65 68 subject = "%s %s" % (email_config.get('email_prefix', ''), subject)
66 69
67 70 if recipients:
68 71 if isinstance(recipients, compat.string_types):
69 72 recipients = recipients.split(',')
70 73 else:
71 74 # if recipients are not defined we send to email_config + all admins
72 75 admins = []
73 76 for u in User.query().filter(User.admin == true()).all():
74 77 if u.email:
75 78 admins.append(u.email)
76 79 recipients = []
77 80 config_email = email_config.get('email_to')
78 81 if config_email:
79 82 recipients += [config_email]
80 83 recipients += admins
81 84
82 85 # translate our LEGACY config into the one that pyramid_mailer supports
83 86 email_conf = dict(
84 87 host=mail_server,
85 88 port=email_config.get('smtp_port', 25),
86 89 username=email_config.get('smtp_username'),
87 90 password=email_config.get('smtp_password'),
88 91
89 92 tls=str2bool(email_config.get('smtp_use_tls')),
90 93 ssl=str2bool(email_config.get('smtp_use_ssl')),
91 94
92 95 # SSL key file
93 96 # keyfile='',
94 97
95 98 # SSL certificate file
96 99 # certfile='',
97 100
98 101 # Location of maildir
99 102 # queue_path='',
100 103
101 104 default_sender=email_config.get('app_email_from', 'RhodeCode'),
102 105
103 106 debug=str2bool(email_config.get('smtp_debug')),
104 107 # /usr/sbin/sendmail Sendmail executable
105 108 # sendmail_app='',
106 109
107 110 # {sendmail_app} -t -i -f {sender} Template for sendmail execution
108 111 # sendmail_template='',
109 112 )
110 113
114 if extra_headers is None:
115 extra_headers = {}
116
117 extra_headers.setdefault('Date', formatdate(time.time()))
118
119 if 'thread_ids' in extra_headers:
120 thread_ids = extra_headers.pop('thread_ids')
121 extra_headers['References'] = ' '.join('<{}>'.format(t) for t in thread_ids)
122
111 123 try:
112 124 mailer = Mailer(**email_conf)
113 125
114 126 message = Message(subject=subject,
115 127 sender=email_conf['default_sender'],
116 128 recipients=recipients,
117 body=body, html=html_body)
129 body=body, html=html_body,
130 extra_headers=extra_headers)
118 131 mailer.send_immediately(message)
119 132
120 133 except Exception:
121 134 log.exception('Mail sending failed')
122 135 return False
123 136 return True
124 137
125 138
126 139 @async_task(ignore_result=True, base=RequestContextTask)
127 140 def create_repo(form_data, cur_user):
128 141 from rhodecode.model.repo import RepoModel
129 142 from rhodecode.model.user import UserModel
130 143 from rhodecode.model.scm import ScmModel
131 144 from rhodecode.model.settings import SettingsModel
132 145
133 146 log = get_logger(create_repo)
134 147
135 148 cur_user = UserModel()._get_user(cur_user)
136 149 owner = cur_user
137 150
138 151 repo_name = form_data['repo_name']
139 152 repo_name_full = form_data['repo_name_full']
140 153 repo_type = form_data['repo_type']
141 154 description = form_data['repo_description']
142 155 private = form_data['repo_private']
143 156 clone_uri = form_data.get('clone_uri')
144 157 repo_group = safe_int(form_data['repo_group'])
145 158 copy_fork_permissions = form_data.get('copy_permissions')
146 159 copy_group_permissions = form_data.get('repo_copy_permissions')
147 160 fork_of = form_data.get('fork_parent_id')
148 161 state = form_data.get('repo_state', Repository.STATE_PENDING)
149 162
150 163 # repo creation defaults, private and repo_type are filled in form
151 164 defs = SettingsModel().get_default_repo_settings(strip_prefix=True)
152 165 enable_statistics = form_data.get(
153 166 'enable_statistics', defs.get('repo_enable_statistics'))
154 167 enable_locking = form_data.get(
155 168 'enable_locking', defs.get('repo_enable_locking'))
156 169 enable_downloads = form_data.get(
157 170 'enable_downloads', defs.get('repo_enable_downloads'))
158 171
159 172 # set landing rev based on default branches for SCM
160 173 landing_ref, _label = ScmModel.backend_landing_ref(repo_type)
161 174
162 175 try:
163 176 RepoModel()._create_repo(
164 177 repo_name=repo_name_full,
165 178 repo_type=repo_type,
166 179 description=description,
167 180 owner=owner,
168 181 private=private,
169 182 clone_uri=clone_uri,
170 183 repo_group=repo_group,
171 184 landing_rev=landing_ref,
172 185 fork_of=fork_of,
173 186 copy_fork_permissions=copy_fork_permissions,
174 187 copy_group_permissions=copy_group_permissions,
175 188 enable_statistics=enable_statistics,
176 189 enable_locking=enable_locking,
177 190 enable_downloads=enable_downloads,
178 191 state=state
179 192 )
180 193 Session().commit()
181 194
182 195 # now create this repo on Filesystem
183 196 RepoModel()._create_filesystem_repo(
184 197 repo_name=repo_name,
185 198 repo_type=repo_type,
186 199 repo_group=RepoModel()._get_repo_group(repo_group),
187 200 clone_uri=clone_uri,
188 201 )
189 202 repo = Repository.get_by_repo_name(repo_name_full)
190 203 hooks_base.create_repository(created_by=owner.username, **repo.get_dict())
191 204
192 205 # update repo commit caches initially
193 206 repo.update_commit_cache()
194 207
195 208 # set new created state
196 209 repo.set_state(Repository.STATE_CREATED)
197 210 repo_id = repo.repo_id
198 211 repo_data = repo.get_api_data()
199 212
200 213 audit_logger.store(
201 214 'repo.create', action_data={'data': repo_data},
202 215 user=cur_user,
203 216 repo=audit_logger.RepoWrap(repo_name=repo_name, repo_id=repo_id))
204 217
205 218 Session().commit()
206 219 except Exception as e:
207 220 log.warning('Exception occurred when creating repository, '
208 221 'doing cleanup...', exc_info=True)
209 222 if isinstance(e, IntegrityError):
210 223 Session().rollback()
211 224
212 225 # rollback things manually !
213 226 repo = Repository.get_by_repo_name(repo_name_full)
214 227 if repo:
215 228 Repository.delete(repo.repo_id)
216 229 Session().commit()
217 230 RepoModel()._delete_filesystem_repo(repo)
218 231 log.info('Cleanup of repo %s finished', repo_name_full)
219 232 raise
220 233
221 234 return True
222 235
223 236
224 237 @async_task(ignore_result=True, base=RequestContextTask)
225 238 def create_repo_fork(form_data, cur_user):
226 239 """
227 240 Creates a fork of repository using internal VCS methods
228 241 """
229 242 from rhodecode.model.repo import RepoModel
230 243 from rhodecode.model.user import UserModel
231 244
232 245 log = get_logger(create_repo_fork)
233 246
234 247 cur_user = UserModel()._get_user(cur_user)
235 248 owner = cur_user
236 249
237 250 repo_name = form_data['repo_name'] # fork in this case
238 251 repo_name_full = form_data['repo_name_full']
239 252 repo_type = form_data['repo_type']
240 253 description = form_data['description']
241 254 private = form_data['private']
242 255 clone_uri = form_data.get('clone_uri')
243 256 repo_group = safe_int(form_data['repo_group'])
244 257 landing_ref = form_data['landing_rev']
245 258 copy_fork_permissions = form_data.get('copy_permissions')
246 259 fork_id = safe_int(form_data.get('fork_parent_id'))
247 260
248 261 try:
249 262 fork_of = RepoModel()._get_repo(fork_id)
250 263 RepoModel()._create_repo(
251 264 repo_name=repo_name_full,
252 265 repo_type=repo_type,
253 266 description=description,
254 267 owner=owner,
255 268 private=private,
256 269 clone_uri=clone_uri,
257 270 repo_group=repo_group,
258 271 landing_rev=landing_ref,
259 272 fork_of=fork_of,
260 273 copy_fork_permissions=copy_fork_permissions
261 274 )
262 275
263 276 Session().commit()
264 277
265 278 base_path = Repository.base_path()
266 279 source_repo_path = os.path.join(base_path, fork_of.repo_name)
267 280
268 281 # now create this repo on Filesystem
269 282 RepoModel()._create_filesystem_repo(
270 283 repo_name=repo_name,
271 284 repo_type=repo_type,
272 285 repo_group=RepoModel()._get_repo_group(repo_group),
273 286 clone_uri=source_repo_path,
274 287 )
275 288 repo = Repository.get_by_repo_name(repo_name_full)
276 289 hooks_base.create_repository(created_by=owner.username, **repo.get_dict())
277 290
278 291 # update repo commit caches initially
279 292 config = repo._config
280 293 config.set('extensions', 'largefiles', '')
281 294 repo.update_commit_cache(config=config)
282 295
283 296 # set new created state
284 297 repo.set_state(Repository.STATE_CREATED)
285 298
286 299 repo_id = repo.repo_id
287 300 repo_data = repo.get_api_data()
288 301 audit_logger.store(
289 302 'repo.fork', action_data={'data': repo_data},
290 303 user=cur_user,
291 304 repo=audit_logger.RepoWrap(repo_name=repo_name, repo_id=repo_id))
292 305
293 306 Session().commit()
294 307 except Exception as e:
295 308 log.warning('Exception occurred when forking repository, '
296 309 'doing cleanup...', exc_info=True)
297 310 if isinstance(e, IntegrityError):
298 311 Session().rollback()
299 312
300 313 # rollback things manually !
301 314 repo = Repository.get_by_repo_name(repo_name_full)
302 315 if repo:
303 316 Repository.delete(repo.repo_id)
304 317 Session().commit()
305 318 RepoModel()._delete_filesystem_repo(repo)
306 319 log.info('Cleanup of repo %s finished', repo_name_full)
307 320 raise
308 321
309 322 return True
310 323
311 324
312 325 @async_task(ignore_result=True)
313 326 def repo_maintenance(repoid):
314 327 from rhodecode.lib import repo_maintenance as repo_maintenance_lib
315 328 log = get_logger(repo_maintenance)
316 329 repo = Repository.get_by_id_or_repo_name(repoid)
317 330 if repo:
318 331 maintenance = repo_maintenance_lib.RepoMaintenance()
319 332 tasks = maintenance.get_tasks_for_repo(repo)
320 333 log.debug('Executing %s tasks on repo `%s`', tasks, repoid)
321 334 executed_types = maintenance.execute(repo)
322 335 log.debug('Got execution results %s', executed_types)
323 336 else:
324 337 log.debug('Repo `%s` not found or without a clone_url', repoid)
325 338
326 339
327 340 @async_task(ignore_result=True)
328 341 def check_for_update():
329 342 from rhodecode.model.update import UpdateModel
330 343 update_url = UpdateModel().get_update_url()
331 344 cur_ver = rhodecode.__version__
332 345
333 346 try:
334 347 data = UpdateModel().get_update_data(update_url)
335 348 latest = data['versions'][0]
336 349 UpdateModel().store_version(latest['version'])
337 350 except Exception:
338 351 pass
339 352
340 353
341 354 @async_task(ignore_result=False)
342 355 def beat_check(*args, **kwargs):
343 356 log = get_logger(beat_check)
344 357 log.info('Got args: %r and kwargs %r', args, kwargs)
345 358 return time.time()
346 359
347 360
348 361 @async_task(ignore_result=True)
349 362 def sync_last_update(*args, **kwargs):
350 363
351 364 skip_repos = kwargs.get('skip_repos')
352 365 if not skip_repos:
353 366 repos = Repository.query() \
354 367 .order_by(Repository.group_id.asc())
355 368
356 369 for repo in repos:
357 370 repo.update_commit_cache()
358 371
359 372 skip_groups = kwargs.get('skip_groups')
360 373 if not skip_groups:
361 374 repo_groups = RepoGroup.query() \
362 375 .filter(RepoGroup.group_parent_id == None)
363 376
364 377 for root_gr in repo_groups:
365 378 for repo_gr in reversed(root_gr.recursive_groups()):
366 379 repo_gr.update_commit_cache()
@@ -1,231 +1,230 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 os
22 22 import time
23 23 import datetime
24 24 import msgpack
25 25 import logging
26 26 import traceback
27 27 import tempfile
28 28 import glob
29 29
30 30 log = logging.getLogger(__name__)
31 31
32 32 # NOTE: Any changes should be synced with exc_tracking at vcsserver.lib.exc_tracking
33 33 global_prefix = 'rhodecode'
34 34 exc_store_dir_name = 'rc_exception_store_v1'
35 35
36 36
37 37 def exc_serialize(exc_id, tb, exc_type, extra_data=None):
38 38
39 39 data = {
40 40 'version': 'v1',
41 41 'exc_id': exc_id,
42 42 'exc_utc_date': datetime.datetime.utcnow().isoformat(),
43 43 'exc_timestamp': repr(time.time()),
44 44 'exc_message': tb,
45 45 'exc_type': exc_type,
46 46 }
47 47 if extra_data:
48 48 data.update(extra_data)
49 49 return msgpack.packb(data), data
50 50
51 51
52 52 def exc_unserialize(tb):
53 53 return msgpack.unpackb(tb)
54 54
55 55 _exc_store = None
56 56
57 57
58 58 def get_exc_store():
59 59 """
60 60 Get and create exception store if it's not existing
61 61 """
62 62 global _exc_store
63 63 import rhodecode as app
64 64
65 65 if _exc_store is not None:
66 66 # quick global cache
67 67 return _exc_store
68 68
69 69 exc_store_dir = app.CONFIG.get('exception_tracker.store_path', '') or tempfile.gettempdir()
70 70 _exc_store_path = os.path.join(exc_store_dir, exc_store_dir_name)
71 71
72 72 _exc_store_path = os.path.abspath(_exc_store_path)
73 73 if not os.path.isdir(_exc_store_path):
74 74 os.makedirs(_exc_store_path)
75 75 log.debug('Initializing exceptions store at %s', _exc_store_path)
76 76 _exc_store = _exc_store_path
77 77
78 78 return _exc_store_path
79 79
80 80
81 81 def _store_exception(exc_id, exc_type_name, exc_traceback, prefix, send_email=None):
82 82 """
83 83 Low level function to store exception in the exception tracker
84 84 """
85 85 from pyramid.threadlocal import get_current_request
86 86 import rhodecode as app
87 87 request = get_current_request()
88 88 extra_data = {}
89 89 # NOTE(marcink): store request information into exc_data
90 90 if request:
91 91 extra_data['client_address'] = getattr(request, 'client_addr', '')
92 92 extra_data['user_agent'] = getattr(request, 'user_agent', '')
93 93 extra_data['method'] = getattr(request, 'method', '')
94 94 extra_data['url'] = getattr(request, 'url', '')
95 95
96 96 exc_store_path = get_exc_store()
97 97 exc_data, org_data = exc_serialize(exc_id, exc_traceback, exc_type_name, extra_data=extra_data)
98 98
99 99 exc_pref_id = '{}_{}_{}'.format(exc_id, prefix, org_data['exc_timestamp'])
100 100 if not os.path.isdir(exc_store_path):
101 101 os.makedirs(exc_store_path)
102 102 stored_exc_path = os.path.join(exc_store_path, exc_pref_id)
103 103 with open(stored_exc_path, 'wb') as f:
104 104 f.write(exc_data)
105 105 log.debug('Stored generated exception %s as: %s', exc_id, stored_exc_path)
106 106
107 107 if send_email is None:
108 108 # NOTE(marcink): read app config unless we specify explicitly
109 109 send_email = app.CONFIG.get('exception_tracker.send_email', False)
110 110
111 111 mail_server = app.CONFIG.get('smtp_server') or None
112 112 send_email = send_email and mail_server
113 113 if send_email:
114 114 try:
115 115 send_exc_email(request, exc_id, exc_type_name)
116 116 except Exception:
117 117 log.exception('Failed to send exception email')
118 118 pass
119 119
120 120
121 121 def send_exc_email(request, exc_id, exc_type_name):
122 122 import rhodecode as app
123 123 from rhodecode.apps._base import TemplateArgs
124 124 from rhodecode.lib.utils2 import aslist
125 125 from rhodecode.lib.celerylib import run_task, tasks
126 126 from rhodecode.lib.base import attach_context_attributes
127 127 from rhodecode.model.notification import EmailNotificationModel
128 128
129 129 recipients = aslist(app.CONFIG.get('exception_tracker.send_email_recipients', ''))
130 130 log.debug('Sending Email exception to: `%s`', recipients or 'all super admins')
131 131
132 132 # NOTE(marcink): needed for email template rendering
133 133 user_id = None
134 134 if request:
135 135 user_id = request.user.user_id
136 136 attach_context_attributes(TemplateArgs(), request, user_id=user_id, is_api=True)
137 137
138 138 email_kwargs = {
139 139 'email_prefix': app.CONFIG.get('exception_tracker.email_prefix', '') or '[RHODECODE ERROR]',
140 140 'exc_url': request.route_url('admin_settings_exception_tracker_show', exception_id=exc_id),
141 141 'exc_id': exc_id,
142 142 'exc_type_name': exc_type_name,
143 143 'exc_traceback': read_exception(exc_id, prefix=None),
144 144 }
145 145
146 (subject, headers, email_body,
147 email_body_plaintext) = EmailNotificationModel().render_email(
146 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
148 147 EmailNotificationModel.TYPE_EMAIL_EXCEPTION, **email_kwargs)
149 148
150 149 run_task(tasks.send_email, recipients, subject,
151 150 email_body_plaintext, email_body)
152 151
153 152
154 153 def _prepare_exception(exc_info):
155 154 exc_type, exc_value, exc_traceback = exc_info
156 155 exc_type_name = exc_type.__name__
157 156
158 157 tb = ''.join(traceback.format_exception(
159 158 exc_type, exc_value, exc_traceback, None))
160 159
161 160 return exc_type_name, tb
162 161
163 162
164 163 def store_exception(exc_id, exc_info, prefix=global_prefix):
165 164 """
166 165 Example usage::
167 166
168 167 exc_info = sys.exc_info()
169 168 store_exception(id(exc_info), exc_info)
170 169 """
171 170
172 171 try:
173 172 exc_type_name, exc_traceback = _prepare_exception(exc_info)
174 173 _store_exception(exc_id=exc_id, exc_type_name=exc_type_name,
175 174 exc_traceback=exc_traceback, prefix=prefix)
176 175 return exc_id, exc_type_name
177 176 except Exception:
178 177 log.exception('Failed to store exception `%s` information', exc_id)
179 178 # there's no way this can fail, it will crash server badly if it does.
180 179 pass
181 180
182 181
183 182 def _find_exc_file(exc_id, prefix=global_prefix):
184 183 exc_store_path = get_exc_store()
185 184 if prefix:
186 185 exc_id = '{}_{}'.format(exc_id, prefix)
187 186 else:
188 187 # search without a prefix
189 188 exc_id = '{}'.format(exc_id)
190 189
191 190 found_exc_id = None
192 191 matches = glob.glob(os.path.join(exc_store_path, exc_id) + '*')
193 192 if matches:
194 193 found_exc_id = matches[0]
195 194
196 195 return found_exc_id
197 196
198 197
199 198 def _read_exception(exc_id, prefix):
200 199 exc_id_file_path = _find_exc_file(exc_id=exc_id, prefix=prefix)
201 200 if exc_id_file_path:
202 201 with open(exc_id_file_path, 'rb') as f:
203 202 return exc_unserialize(f.read())
204 203 else:
205 204 log.debug('Exception File `%s` not found', exc_id_file_path)
206 205 return None
207 206
208 207
209 208 def read_exception(exc_id, prefix=global_prefix):
210 209 try:
211 210 return _read_exception(exc_id=exc_id, prefix=prefix)
212 211 except Exception:
213 212 log.exception('Failed to read exception `%s` information', exc_id)
214 213 # there's no way this can fail, it will crash server badly if it does.
215 214 return None
216 215
217 216
218 217 def delete_exception(exc_id, prefix=global_prefix):
219 218 try:
220 219 exc_id_file_path = _find_exc_file(exc_id, prefix=prefix)
221 220 if exc_id_file_path:
222 221 os.remove(exc_id_file_path)
223 222
224 223 except Exception:
225 224 log.exception('Failed to remove exception `%s` information', exc_id)
226 225 # there's no way this can fail, it will crash server badly if it does.
227 226 pass
228 227
229 228
230 229 def generate_id():
231 230 return id(object())
@@ -1,836 +1,840 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[
95 95 _co.pull_request_version_id].append(_co) for _co in comments]
96 96
97 97 def yield_comments(pos):
98 98 for co in comment_groups[pos]:
99 99 yield co
100 100
101 101 comment_versions = collections.defaultdict(
102 102 lambda: collections.defaultdict(list))
103 103 prev_prvid = -1
104 104 # fake last entry with None, to aggregate on "latest" version which
105 105 # doesn't have an pull_request_version_id
106 106 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
107 107 prvid = ver.pull_request_version_id
108 108 if prev_prvid == -1:
109 109 prev_prvid = prvid
110 110
111 111 for co in yield_comments(prvid):
112 112 comment_versions[prvid]['at'].append(co)
113 113
114 114 # save until
115 115 current = comment_versions[prvid]['at']
116 116 prev_until = comment_versions[prev_prvid]['until']
117 117 cur_until = prev_until + current
118 118 comment_versions[prvid]['until'].extend(cur_until)
119 119
120 120 # save outdated
121 121 if inline:
122 122 outdated = [x for x in cur_until
123 123 if x.outdated_at_version(show_version)]
124 124 else:
125 125 outdated = [x for x in cur_until
126 126 if x.older_than_version(show_version)]
127 127 display = [x for x in cur_until if x not in outdated]
128 128
129 129 comment_versions[prvid]['outdated'] = outdated
130 130 comment_versions[prvid]['display'] = display
131 131
132 132 prev_prvid = prvid
133 133
134 134 return comment_versions
135 135
136 136 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
137 137 qry = Session().query(ChangesetComment) \
138 138 .filter(ChangesetComment.repo == repo)
139 139
140 140 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
141 141 qry = qry.filter(ChangesetComment.comment_type == comment_type)
142 142
143 143 if user:
144 144 user = self._get_user(user)
145 145 if user:
146 146 qry = qry.filter(ChangesetComment.user_id == user.user_id)
147 147
148 148 if commit_id:
149 149 qry = qry.filter(ChangesetComment.revision == commit_id)
150 150
151 151 qry = qry.order_by(ChangesetComment.created_on)
152 152 return qry.all()
153 153
154 154 def get_repository_unresolved_todos(self, repo):
155 155 todos = Session().query(ChangesetComment) \
156 156 .filter(ChangesetComment.repo == repo) \
157 157 .filter(ChangesetComment.resolved_by == None) \
158 158 .filter(ChangesetComment.comment_type
159 159 == ChangesetComment.COMMENT_TYPE_TODO)
160 160 todos = todos.all()
161 161
162 162 return todos
163 163
164 164 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
165 165
166 166 todos = Session().query(ChangesetComment) \
167 167 .filter(ChangesetComment.pull_request == pull_request) \
168 168 .filter(ChangesetComment.resolved_by == None) \
169 169 .filter(ChangesetComment.comment_type
170 170 == ChangesetComment.COMMENT_TYPE_TODO)
171 171
172 172 if not show_outdated:
173 173 todos = todos.filter(
174 174 coalesce(ChangesetComment.display_state, '') !=
175 175 ChangesetComment.COMMENT_OUTDATED)
176 176
177 177 todos = todos.all()
178 178
179 179 return todos
180 180
181 181 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
182 182
183 183 todos = Session().query(ChangesetComment) \
184 184 .filter(ChangesetComment.pull_request == pull_request) \
185 185 .filter(ChangesetComment.resolved_by != None) \
186 186 .filter(ChangesetComment.comment_type
187 187 == ChangesetComment.COMMENT_TYPE_TODO)
188 188
189 189 if not show_outdated:
190 190 todos = todos.filter(
191 191 coalesce(ChangesetComment.display_state, '') !=
192 192 ChangesetComment.COMMENT_OUTDATED)
193 193
194 194 todos = todos.all()
195 195
196 196 return todos
197 197
198 198 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
199 199
200 200 todos = Session().query(ChangesetComment) \
201 201 .filter(ChangesetComment.revision == commit_id) \
202 202 .filter(ChangesetComment.resolved_by == None) \
203 203 .filter(ChangesetComment.comment_type
204 204 == ChangesetComment.COMMENT_TYPE_TODO)
205 205
206 206 if not show_outdated:
207 207 todos = todos.filter(
208 208 coalesce(ChangesetComment.display_state, '') !=
209 209 ChangesetComment.COMMENT_OUTDATED)
210 210
211 211 todos = todos.all()
212 212
213 213 return todos
214 214
215 215 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
216 216
217 217 todos = Session().query(ChangesetComment) \
218 218 .filter(ChangesetComment.revision == commit_id) \
219 219 .filter(ChangesetComment.resolved_by != None) \
220 220 .filter(ChangesetComment.comment_type
221 221 == ChangesetComment.COMMENT_TYPE_TODO)
222 222
223 223 if not show_outdated:
224 224 todos = todos.filter(
225 225 coalesce(ChangesetComment.display_state, '') !=
226 226 ChangesetComment.COMMENT_OUTDATED)
227 227
228 228 todos = todos.all()
229 229
230 230 return todos
231 231
232 232 def _log_audit_action(self, action, action_data, auth_user, comment):
233 233 audit_logger.store(
234 234 action=action,
235 235 action_data=action_data,
236 236 user=auth_user,
237 237 repo=comment.repo)
238 238
239 239 def create(self, text, repo, user, commit_id=None, pull_request=None,
240 240 f_path=None, line_no=None, status_change=None,
241 241 status_change_type=None, comment_type=None,
242 242 resolves_comment_id=None, closing_pr=False, send_email=True,
243 243 renderer=None, auth_user=None, extra_recipients=None):
244 244 """
245 245 Creates new comment for commit or pull request.
246 246 IF status_change is not none this comment is associated with a
247 247 status change of commit or commit associated with pull request
248 248
249 249 :param text:
250 250 :param repo:
251 251 :param user:
252 252 :param commit_id:
253 253 :param pull_request:
254 254 :param f_path:
255 255 :param line_no:
256 256 :param status_change: Label for status change
257 257 :param comment_type: Type of comment
258 258 :param resolves_comment_id: id of comment which this one will resolve
259 259 :param status_change_type: type of status change
260 260 :param closing_pr:
261 261 :param send_email:
262 262 :param renderer: pick renderer for this comment
263 263 :param auth_user: current authenticated user calling this method
264 264 :param extra_recipients: list of extra users to be added to recipients
265 265 """
266 266
267 267 if not text:
268 268 log.warning('Missing text for comment, skipping...')
269 269 return
270 270 request = get_current_request()
271 271 _ = request.translate
272 272
273 273 if not renderer:
274 274 renderer = self._get_renderer(request=request)
275 275
276 276 repo = self._get_repo(repo)
277 277 user = self._get_user(user)
278 278 auth_user = auth_user or user
279 279
280 280 schema = comment_schema.CommentSchema()
281 281 validated_kwargs = schema.deserialize(dict(
282 282 comment_body=text,
283 283 comment_type=comment_type,
284 284 comment_file=f_path,
285 285 comment_line=line_no,
286 286 renderer_type=renderer,
287 287 status_change=status_change_type,
288 288 resolves_comment_id=resolves_comment_id,
289 289 repo=repo.repo_id,
290 290 user=user.user_id,
291 291 ))
292 292
293 293 comment = ChangesetComment()
294 294 comment.renderer = validated_kwargs['renderer_type']
295 295 comment.text = validated_kwargs['comment_body']
296 296 comment.f_path = validated_kwargs['comment_file']
297 297 comment.line_no = validated_kwargs['comment_line']
298 298 comment.comment_type = validated_kwargs['comment_type']
299 299
300 300 comment.repo = repo
301 301 comment.author = user
302 302 resolved_comment = self.__get_commit_comment(
303 303 validated_kwargs['resolves_comment_id'])
304 304 # check if the comment actually belongs to this PR
305 305 if resolved_comment and resolved_comment.pull_request and \
306 306 resolved_comment.pull_request != pull_request:
307 307 log.warning('Comment tried to resolved unrelated todo comment: %s',
308 308 resolved_comment)
309 309 # comment not bound to this pull request, forbid
310 310 resolved_comment = None
311 311
312 312 elif resolved_comment and resolved_comment.repo and \
313 313 resolved_comment.repo != repo:
314 314 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 315 resolved_comment)
316 316 # comment not bound to this repo, forbid
317 317 resolved_comment = None
318 318
319 319 comment.resolved_comment = resolved_comment
320 320
321 321 pull_request_id = pull_request
322 322
323 323 commit_obj = None
324 324 pull_request_obj = None
325 325
326 326 if commit_id:
327 327 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
328 328 # do a lookup, so we don't pass something bad here
329 329 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
330 330 comment.revision = commit_obj.raw_id
331 331
332 332 elif pull_request_id:
333 333 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
334 334 pull_request_obj = self.__get_pull_request(pull_request_id)
335 335 comment.pull_request = pull_request_obj
336 336 else:
337 337 raise Exception('Please specify commit or pull_request_id')
338 338
339 339 Session().add(comment)
340 340 Session().flush()
341 341 kwargs = {
342 342 'user': user,
343 343 'renderer_type': renderer,
344 344 'repo_name': repo.repo_name,
345 345 'status_change': status_change,
346 346 'status_change_type': status_change_type,
347 347 'comment_body': text,
348 348 'comment_file': f_path,
349 349 'comment_line': line_no,
350 350 'comment_type': comment_type or 'note',
351 351 'comment_id': comment.comment_id
352 352 }
353 353
354 354 if commit_obj:
355 355 recipients = ChangesetComment.get_users(
356 356 revision=commit_obj.raw_id)
357 357 # add commit author if it's in RhodeCode system
358 358 cs_author = User.get_from_cs_author(commit_obj.author)
359 359 if not cs_author:
360 360 # use repo owner if we cannot extract the author correctly
361 361 cs_author = repo.user
362 362 recipients += [cs_author]
363 363
364 364 commit_comment_url = self.get_url(comment, request=request)
365 365 commit_comment_reply_url = self.get_url(
366 366 comment, request=request,
367 367 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
368 368
369 369 target_repo_url = h.link_to(
370 370 repo.repo_name,
371 371 h.route_url('repo_summary', repo_name=repo.repo_name))
372 372
373 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
374 commit_id=commit_id)
375
373 376 # commit specifics
374 377 kwargs.update({
375 378 'commit': commit_obj,
376 379 'commit_message': commit_obj.message,
377 380 'commit_target_repo_url': target_repo_url,
378 381 'commit_comment_url': commit_comment_url,
379 'commit_comment_reply_url': commit_comment_reply_url
382 'commit_comment_reply_url': commit_comment_reply_url,
383 'commit_url': commit_url,
384 'thread_ids': [commit_url, commit_comment_url],
380 385 })
381 386
382 387 elif pull_request_obj:
383 388 # get the current participants of this pull request
384 389 recipients = ChangesetComment.get_users(
385 390 pull_request_id=pull_request_obj.pull_request_id)
386 391 # add pull request author
387 392 recipients += [pull_request_obj.author]
388 393
389 394 # add the reviewers to notification
390 395 recipients += [x.user for x in pull_request_obj.reviewers]
391 396
392 397 pr_target_repo = pull_request_obj.target_repo
393 398 pr_source_repo = pull_request_obj.source_repo
394 399
395 400 pr_comment_url = self.get_url(comment, request=request)
396 401 pr_comment_reply_url = self.get_url(
397 402 comment, request=request,
398 403 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
399 404
400 405 pr_url = h.route_url(
401 406 'pullrequest_show',
402 407 repo_name=pr_target_repo.repo_name,
403 408 pull_request_id=pull_request_obj.pull_request_id, )
404 409
405 410 # set some variables for email notification
406 411 pr_target_repo_url = h.route_url(
407 412 'repo_summary', repo_name=pr_target_repo.repo_name)
408 413
409 414 pr_source_repo_url = h.route_url(
410 415 'repo_summary', repo_name=pr_source_repo.repo_name)
411 416
412 417 # pull request specifics
413 418 kwargs.update({
414 419 'pull_request': pull_request_obj,
415 420 'pr_id': pull_request_obj.pull_request_id,
416 421 'pull_request_url': pr_url,
417 422 'pull_request_target_repo': pr_target_repo,
418 423 'pull_request_target_repo_url': pr_target_repo_url,
419 424 'pull_request_source_repo': pr_source_repo,
420 425 'pull_request_source_repo_url': pr_source_repo_url,
421 426 'pr_comment_url': pr_comment_url,
422 427 'pr_comment_reply_url': pr_comment_reply_url,
423 428 'pr_closing': closing_pr,
429 'thread_ids': [pr_url, pr_comment_url],
424 430 })
425 431
426 432 recipients += [self._get_user(u) for u in (extra_recipients or [])]
427 433
428 434 if send_email:
429 435 # pre-generate the subject for notification itself
430 (subject,
431 _h, _e, # we don't care about those
432 body_plaintext) = EmailNotificationModel().render_email(
436 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
433 437 notification_type, **kwargs)
434 438
435 439 mention_recipients = set(
436 440 self._extract_mentions(text)).difference(recipients)
437 441
438 442 # create notification objects, and emails
439 443 NotificationModel().create(
440 444 created_by=user,
441 445 notification_subject=subject,
442 446 notification_body=body_plaintext,
443 447 notification_type=notification_type,
444 448 recipients=recipients,
445 449 mention_recipients=mention_recipients,
446 450 email_kwargs=kwargs,
447 451 )
448 452
449 453 Session().flush()
450 454 if comment.pull_request:
451 455 action = 'repo.pull_request.comment.create'
452 456 else:
453 457 action = 'repo.commit.comment.create'
454 458
455 459 comment_data = comment.get_api_data()
456 460 self._log_audit_action(
457 461 action, {'data': comment_data}, auth_user, comment)
458 462
459 463 msg_url = ''
460 464 channel = None
461 465 if commit_obj:
462 466 msg_url = commit_comment_url
463 467 repo_name = repo.repo_name
464 468 channel = u'/repo${}$/commit/{}'.format(
465 469 repo_name,
466 470 commit_obj.raw_id
467 471 )
468 472 elif pull_request_obj:
469 473 msg_url = pr_comment_url
470 474 repo_name = pr_target_repo.repo_name
471 475 channel = u'/repo${}$/pr/{}'.format(
472 476 repo_name,
473 477 pull_request_id
474 478 )
475 479
476 480 message = '<strong>{}</strong> {} - ' \
477 481 '<a onclick="window.location=\'{}\';' \
478 482 'window.location.reload()">' \
479 483 '<strong>{}</strong></a>'
480 484 message = message.format(
481 485 user.username, _('made a comment'), msg_url,
482 486 _('Show it now'))
483 487
484 488 channelstream.post_message(
485 489 channel, message, user.username,
486 490 registry=get_current_registry())
487 491
488 492 return comment
489 493
490 494 def edit(self, comment_id, text, auth_user, version):
491 495 """
492 496 Change existing comment for commit or pull request.
493 497
494 498 :param comment_id:
495 499 :param text:
496 500 :param auth_user: current authenticated user calling this method
497 501 :param version: last comment version
498 502 """
499 503 if not text:
500 504 log.warning('Missing text for comment, skipping...')
501 505 return
502 506
503 507 comment = ChangesetComment.get(comment_id)
504 508 old_comment_text = comment.text
505 509 comment.text = text
506 510 comment.modified_at = datetime.datetime.now()
507 511 version = safe_int(version)
508 512
509 513 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
510 514 # would return 3 here
511 515 comment_version = ChangesetCommentHistory.get_version(comment_id)
512 516
513 517 if isinstance(version, (int, long)) and (comment_version - version) != 1:
514 518 log.warning(
515 519 'Version mismatch comment_version {} submitted {}, skipping'.format(
516 520 comment_version-1, # -1 since note above
517 521 version
518 522 )
519 523 )
520 524 raise CommentVersionMismatch()
521 525
522 526 comment_history = ChangesetCommentHistory()
523 527 comment_history.comment_id = comment_id
524 528 comment_history.version = comment_version
525 529 comment_history.created_by_user_id = auth_user.user_id
526 530 comment_history.text = old_comment_text
527 531 # TODO add email notification
528 532 Session().add(comment_history)
529 533 Session().add(comment)
530 534 Session().flush()
531 535
532 536 if comment.pull_request:
533 537 action = 'repo.pull_request.comment.edit'
534 538 else:
535 539 action = 'repo.commit.comment.edit'
536 540
537 541 comment_data = comment.get_api_data()
538 542 comment_data['old_comment_text'] = old_comment_text
539 543 self._log_audit_action(
540 544 action, {'data': comment_data}, auth_user, comment)
541 545
542 546 return comment_history
543 547
544 548 def delete(self, comment, auth_user):
545 549 """
546 550 Deletes given comment
547 551 """
548 552 comment = self.__get_commit_comment(comment)
549 553 old_data = comment.get_api_data()
550 554 Session().delete(comment)
551 555
552 556 if comment.pull_request:
553 557 action = 'repo.pull_request.comment.delete'
554 558 else:
555 559 action = 'repo.commit.comment.delete'
556 560
557 561 self._log_audit_action(
558 562 action, {'old_data': old_data}, auth_user, comment)
559 563
560 564 return comment
561 565
562 566 def get_all_comments(self, repo_id, revision=None, pull_request=None):
563 567 q = ChangesetComment.query()\
564 568 .filter(ChangesetComment.repo_id == repo_id)
565 569 if revision:
566 570 q = q.filter(ChangesetComment.revision == revision)
567 571 elif pull_request:
568 572 pull_request = self.__get_pull_request(pull_request)
569 573 q = q.filter(ChangesetComment.pull_request == pull_request)
570 574 else:
571 575 raise Exception('Please specify commit or pull_request')
572 576 q = q.order_by(ChangesetComment.created_on)
573 577 return q.all()
574 578
575 579 def get_url(self, comment, request=None, permalink=False, anchor=None):
576 580 if not request:
577 581 request = get_current_request()
578 582
579 583 comment = self.__get_commit_comment(comment)
580 584 if anchor is None:
581 585 anchor = 'comment-{}'.format(comment.comment_id)
582 586
583 587 if comment.pull_request:
584 588 pull_request = comment.pull_request
585 589 if permalink:
586 590 return request.route_url(
587 591 'pull_requests_global',
588 592 pull_request_id=pull_request.pull_request_id,
589 593 _anchor=anchor)
590 594 else:
591 595 return request.route_url(
592 596 'pullrequest_show',
593 597 repo_name=safe_str(pull_request.target_repo.repo_name),
594 598 pull_request_id=pull_request.pull_request_id,
595 599 _anchor=anchor)
596 600
597 601 else:
598 602 repo = comment.repo
599 603 commit_id = comment.revision
600 604
601 605 if permalink:
602 606 return request.route_url(
603 607 'repo_commit', repo_name=safe_str(repo.repo_id),
604 608 commit_id=commit_id,
605 609 _anchor=anchor)
606 610
607 611 else:
608 612 return request.route_url(
609 613 'repo_commit', repo_name=safe_str(repo.repo_name),
610 614 commit_id=commit_id,
611 615 _anchor=anchor)
612 616
613 617 def get_comments(self, repo_id, revision=None, pull_request=None):
614 618 """
615 619 Gets main comments based on revision or pull_request_id
616 620
617 621 :param repo_id:
618 622 :param revision:
619 623 :param pull_request:
620 624 """
621 625
622 626 q = ChangesetComment.query()\
623 627 .filter(ChangesetComment.repo_id == repo_id)\
624 628 .filter(ChangesetComment.line_no == None)\
625 629 .filter(ChangesetComment.f_path == None)
626 630 if revision:
627 631 q = q.filter(ChangesetComment.revision == revision)
628 632 elif pull_request:
629 633 pull_request = self.__get_pull_request(pull_request)
630 634 q = q.filter(ChangesetComment.pull_request == pull_request)
631 635 else:
632 636 raise Exception('Please specify commit or pull_request')
633 637 q = q.order_by(ChangesetComment.created_on)
634 638 return q.all()
635 639
636 640 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
637 641 q = self._get_inline_comments_query(repo_id, revision, pull_request)
638 642 return self._group_comments_by_path_and_line_number(q)
639 643
640 644 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
641 645 version=None):
642 646 inline_cnt = 0
643 647 for fname, per_line_comments in inline_comments.iteritems():
644 648 for lno, comments in per_line_comments.iteritems():
645 649 for comm in comments:
646 650 if not comm.outdated_at_version(version) and skip_outdated:
647 651 inline_cnt += 1
648 652
649 653 return inline_cnt
650 654
651 655 def get_outdated_comments(self, repo_id, pull_request):
652 656 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
653 657 # of a pull request.
654 658 q = self._all_inline_comments_of_pull_request(pull_request)
655 659 q = q.filter(
656 660 ChangesetComment.display_state ==
657 661 ChangesetComment.COMMENT_OUTDATED
658 662 ).order_by(ChangesetComment.comment_id.asc())
659 663
660 664 return self._group_comments_by_path_and_line_number(q)
661 665
662 666 def _get_inline_comments_query(self, repo_id, revision, pull_request):
663 667 # TODO: johbo: Split this into two methods: One for PR and one for
664 668 # commit.
665 669 if revision:
666 670 q = Session().query(ChangesetComment).filter(
667 671 ChangesetComment.repo_id == repo_id,
668 672 ChangesetComment.line_no != null(),
669 673 ChangesetComment.f_path != null(),
670 674 ChangesetComment.revision == revision)
671 675
672 676 elif pull_request:
673 677 pull_request = self.__get_pull_request(pull_request)
674 678 if not CommentsModel.use_outdated_comments(pull_request):
675 679 q = self._visible_inline_comments_of_pull_request(pull_request)
676 680 else:
677 681 q = self._all_inline_comments_of_pull_request(pull_request)
678 682
679 683 else:
680 684 raise Exception('Please specify commit or pull_request_id')
681 685 q = q.order_by(ChangesetComment.comment_id.asc())
682 686 return q
683 687
684 688 def _group_comments_by_path_and_line_number(self, q):
685 689 comments = q.all()
686 690 paths = collections.defaultdict(lambda: collections.defaultdict(list))
687 691 for co in comments:
688 692 paths[co.f_path][co.line_no].append(co)
689 693 return paths
690 694
691 695 @classmethod
692 696 def needed_extra_diff_context(cls):
693 697 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
694 698
695 699 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
696 700 if not CommentsModel.use_outdated_comments(pull_request):
697 701 return
698 702
699 703 comments = self._visible_inline_comments_of_pull_request(pull_request)
700 704 comments_to_outdate = comments.all()
701 705
702 706 for comment in comments_to_outdate:
703 707 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
704 708
705 709 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
706 710 diff_line = _parse_comment_line_number(comment.line_no)
707 711
708 712 try:
709 713 old_context = old_diff_proc.get_context_of_line(
710 714 path=comment.f_path, diff_line=diff_line)
711 715 new_context = new_diff_proc.get_context_of_line(
712 716 path=comment.f_path, diff_line=diff_line)
713 717 except (diffs.LineNotInDiffException,
714 718 diffs.FileNotInDiffException):
715 719 comment.display_state = ChangesetComment.COMMENT_OUTDATED
716 720 return
717 721
718 722 if old_context == new_context:
719 723 return
720 724
721 725 if self._should_relocate_diff_line(diff_line):
722 726 new_diff_lines = new_diff_proc.find_context(
723 727 path=comment.f_path, context=old_context,
724 728 offset=self.DIFF_CONTEXT_BEFORE)
725 729 if not new_diff_lines:
726 730 comment.display_state = ChangesetComment.COMMENT_OUTDATED
727 731 else:
728 732 new_diff_line = self._choose_closest_diff_line(
729 733 diff_line, new_diff_lines)
730 734 comment.line_no = _diff_to_comment_line_number(new_diff_line)
731 735 else:
732 736 comment.display_state = ChangesetComment.COMMENT_OUTDATED
733 737
734 738 def _should_relocate_diff_line(self, diff_line):
735 739 """
736 740 Checks if relocation shall be tried for the given `diff_line`.
737 741
738 742 If a comment points into the first lines, then we can have a situation
739 743 that after an update another line has been added on top. In this case
740 744 we would find the context still and move the comment around. This
741 745 would be wrong.
742 746 """
743 747 should_relocate = (
744 748 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
745 749 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
746 750 return should_relocate
747 751
748 752 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
749 753 candidate = new_diff_lines[0]
750 754 best_delta = _diff_line_delta(diff_line, candidate)
751 755 for new_diff_line in new_diff_lines[1:]:
752 756 delta = _diff_line_delta(diff_line, new_diff_line)
753 757 if delta < best_delta:
754 758 candidate = new_diff_line
755 759 best_delta = delta
756 760 return candidate
757 761
758 762 def _visible_inline_comments_of_pull_request(self, pull_request):
759 763 comments = self._all_inline_comments_of_pull_request(pull_request)
760 764 comments = comments.filter(
761 765 coalesce(ChangesetComment.display_state, '') !=
762 766 ChangesetComment.COMMENT_OUTDATED)
763 767 return comments
764 768
765 769 def _all_inline_comments_of_pull_request(self, pull_request):
766 770 comments = Session().query(ChangesetComment)\
767 771 .filter(ChangesetComment.line_no != None)\
768 772 .filter(ChangesetComment.f_path != None)\
769 773 .filter(ChangesetComment.pull_request == pull_request)
770 774 return comments
771 775
772 776 def _all_general_comments_of_pull_request(self, pull_request):
773 777 comments = Session().query(ChangesetComment)\
774 778 .filter(ChangesetComment.line_no == None)\
775 779 .filter(ChangesetComment.f_path == None)\
776 780 .filter(ChangesetComment.pull_request == pull_request)
777 781
778 782 return comments
779 783
780 784 @staticmethod
781 785 def use_outdated_comments(pull_request):
782 786 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
783 787 settings = settings_model.get_general_settings()
784 788 return settings.get('rhodecode_use_outdated_comments', False)
785 789
786 790 def trigger_commit_comment_hook(self, repo, user, action, data=None):
787 791 repo = self._get_repo(repo)
788 792 target_scm = repo.scm_instance()
789 793 if action == 'create':
790 794 trigger_hook = hooks_utils.trigger_comment_commit_hooks
791 795 elif action == 'edit':
792 796 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
793 797 else:
794 798 return
795 799
796 800 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
797 801 repo, action, trigger_hook)
798 802 trigger_hook(
799 803 username=user.username,
800 804 repo_name=repo.repo_name,
801 805 repo_type=target_scm.alias,
802 806 repo=repo,
803 807 data=data)
804 808
805 809
806 810 def _parse_comment_line_number(line_no):
807 811 """
808 812 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
809 813 """
810 814 old_line = None
811 815 new_line = None
812 816 if line_no.startswith('o'):
813 817 old_line = int(line_no[1:])
814 818 elif line_no.startswith('n'):
815 819 new_line = int(line_no[1:])
816 820 else:
817 821 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
818 822 return diffs.DiffLineNumber(old_line, new_line)
819 823
820 824
821 825 def _diff_to_comment_line_number(diff_line):
822 826 if diff_line.new is not None:
823 827 return u'n{}'.format(diff_line.new)
824 828 elif diff_line.old is not None:
825 829 return u'o{}'.format(diff_line.old)
826 830 return u''
827 831
828 832
829 833 def _diff_line_delta(a, b):
830 834 if None not in (a.new, b.new):
831 835 return abs(a.new - b.new)
832 836 elif None not in (a.old, b.old):
833 837 return abs(a.old - b.old)
834 838 else:
835 839 raise ValueError(
836 840 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,411 +1,406 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 """
23 23 Model for notifications
24 24 """
25 25
26 26 import logging
27 27 import traceback
28 28
29 29 import premailer
30 30 from pyramid.threadlocal import get_current_request
31 31 from sqlalchemy.sql.expression import false, true
32 32
33 33 import rhodecode
34 34 from rhodecode.lib import helpers as h
35 35 from rhodecode.model import BaseModel
36 36 from rhodecode.model.db import Notification, User, UserNotification
37 37 from rhodecode.model.meta import Session
38 38 from rhodecode.translation import TranslationString
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42
43 43 class NotificationModel(BaseModel):
44 44
45 45 cls = Notification
46 46
47 47 def __get_notification(self, notification):
48 48 if isinstance(notification, Notification):
49 49 return notification
50 50 elif isinstance(notification, (int, long)):
51 51 return Notification.get(notification)
52 52 else:
53 53 if notification:
54 54 raise Exception('notification must be int, long or Instance'
55 55 ' of Notification got %s' % type(notification))
56 56
57 57 def create(
58 58 self, created_by, notification_subject, notification_body,
59 59 notification_type=Notification.TYPE_MESSAGE, recipients=None,
60 60 mention_recipients=None, with_email=True, email_kwargs=None):
61 61 """
62 62
63 63 Creates notification of given type
64 64
65 65 :param created_by: int, str or User instance. User who created this
66 66 notification
67 67 :param notification_subject: subject of notification itself
68 68 :param notification_body: body of notification text
69 69 :param notification_type: type of notification, based on that we
70 70 pick templates
71 71
72 72 :param recipients: list of int, str or User objects, when None
73 73 is given send to all admins
74 74 :param mention_recipients: list of int, str or User objects,
75 75 that were mentioned
76 76 :param with_email: send email with this notification
77 77 :param email_kwargs: dict with arguments to generate email
78 78 """
79 79
80 80 from rhodecode.lib.celerylib import tasks, run_task
81 81
82 82 if recipients and not getattr(recipients, '__iter__', False):
83 83 raise Exception('recipients must be an iterable object')
84 84
85 85 created_by_obj = self._get_user(created_by)
86 86 # default MAIN body if not given
87 87 email_kwargs = email_kwargs or {'body': notification_body}
88 88 mention_recipients = mention_recipients or set()
89 89
90 90 if not created_by_obj:
91 91 raise Exception('unknown user %s' % created_by)
92 92
93 93 if recipients is None:
94 94 # recipients is None means to all admins
95 95 recipients_objs = User.query().filter(User.admin == true()).all()
96 96 log.debug('sending notifications %s to admins: %s',
97 97 notification_type, recipients_objs)
98 98 else:
99 99 recipients_objs = set()
100 100 for u in recipients:
101 101 obj = self._get_user(u)
102 102 if obj:
103 103 recipients_objs.add(obj)
104 104 else: # we didn't find this user, log the error and carry on
105 105 log.error('cannot notify unknown user %r', u)
106 106
107 107 if not recipients_objs:
108 108 raise Exception('no valid recipients specified')
109 109
110 110 log.debug('sending notifications %s to %s',
111 111 notification_type, recipients_objs)
112 112
113 113 # add mentioned users into recipients
114 114 final_recipients = set(recipients_objs).union(mention_recipients)
115 115
116 116 notification = Notification.create(
117 117 created_by=created_by_obj, subject=notification_subject,
118 118 body=notification_body, recipients=final_recipients,
119 119 type_=notification_type
120 120 )
121 121
122 122 if not with_email: # skip sending email, and just create notification
123 123 return notification
124 124
125 125 # don't send email to person who created this comment
126 126 rec_objs = set(recipients_objs).difference({created_by_obj})
127 127
128 128 # now notify all recipients in question
129 129
130 130 for recipient in rec_objs.union(mention_recipients):
131 131 # inject current recipient
132 132 email_kwargs['recipient'] = recipient
133 133 email_kwargs['mention'] = recipient in mention_recipients
134 (subject, headers, email_body,
135 email_body_plaintext) = EmailNotificationModel().render_email(
134 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
136 135 notification_type, **email_kwargs)
137 136
138 log.debug(
139 'Creating notification email task for user:`%s`', recipient)
137 extra_headers = None
138 if 'thread_ids' in email_kwargs:
139 extra_headers = {'thread_ids': email_kwargs.pop('thread_ids')}
140
141 log.debug('Creating notification email task for user:`%s`', recipient)
140 142 task = run_task(
141 143 tasks.send_email, recipient.email, subject,
142 email_body_plaintext, email_body)
144 email_body_plaintext, email_body, extra_headers=extra_headers)
143 145 log.debug('Created email task: %s', task)
144 146
145 147 return notification
146 148
147 149 def delete(self, user, notification):
148 150 # we don't want to remove actual notification just the assignment
149 151 try:
150 152 notification = self.__get_notification(notification)
151 153 user = self._get_user(user)
152 154 if notification and user:
153 155 obj = UserNotification.query()\
154 156 .filter(UserNotification.user == user)\
155 157 .filter(UserNotification.notification == notification)\
156 158 .one()
157 159 Session().delete(obj)
158 160 return True
159 161 except Exception:
160 162 log.error(traceback.format_exc())
161 163 raise
162 164
163 165 def get_for_user(self, user, filter_=None):
164 166 """
165 167 Get mentions for given user, filter them if filter dict is given
166 168 """
167 169 user = self._get_user(user)
168 170
169 171 q = UserNotification.query()\
170 172 .filter(UserNotification.user == user)\
171 173 .join((
172 174 Notification, UserNotification.notification_id ==
173 175 Notification.notification_id))
174 176 if filter_ == ['all']:
175 177 q = q # no filter
176 178 elif filter_ == ['unread']:
177 179 q = q.filter(UserNotification.read == false())
178 180 elif filter_:
179 181 q = q.filter(Notification.type_.in_(filter_))
180 182
181 183 return q
182 184
183 185 def mark_read(self, user, notification):
184 186 try:
185 187 notification = self.__get_notification(notification)
186 188 user = self._get_user(user)
187 189 if notification and user:
188 190 obj = UserNotification.query()\
189 191 .filter(UserNotification.user == user)\
190 192 .filter(UserNotification.notification == notification)\
191 193 .one()
192 194 obj.read = True
193 195 Session().add(obj)
194 196 return True
195 197 except Exception:
196 198 log.error(traceback.format_exc())
197 199 raise
198 200
199 201 def mark_all_read_for_user(self, user, filter_=None):
200 202 user = self._get_user(user)
201 203 q = UserNotification.query()\
202 204 .filter(UserNotification.user == user)\
203 205 .filter(UserNotification.read == false())\
204 206 .join((
205 207 Notification, UserNotification.notification_id ==
206 208 Notification.notification_id))
207 209 if filter_ == ['unread']:
208 210 q = q.filter(UserNotification.read == false())
209 211 elif filter_:
210 212 q = q.filter(Notification.type_.in_(filter_))
211 213
212 214 # this is a little inefficient but sqlalchemy doesn't support
213 215 # update on joined tables :(
214 216 for obj in q.all():
215 217 obj.read = True
216 218 Session().add(obj)
217 219
218 220 def get_unread_cnt_for_user(self, user):
219 221 user = self._get_user(user)
220 222 return UserNotification.query()\
221 223 .filter(UserNotification.read == false())\
222 224 .filter(UserNotification.user == user).count()
223 225
224 226 def get_unread_for_user(self, user):
225 227 user = self._get_user(user)
226 228 return [x.notification for x in UserNotification.query()
227 229 .filter(UserNotification.read == false())
228 230 .filter(UserNotification.user == user).all()]
229 231
230 232 def get_user_notification(self, user, notification):
231 233 user = self._get_user(user)
232 234 notification = self.__get_notification(notification)
233 235
234 236 return UserNotification.query()\
235 237 .filter(UserNotification.notification == notification)\
236 238 .filter(UserNotification.user == user).scalar()
237 239
238 240 def make_description(self, notification, translate, show_age=True):
239 241 """
240 242 Creates a human readable description based on properties
241 243 of notification object
242 244 """
243 245 _ = translate
244 246 _map = {
245 247 notification.TYPE_CHANGESET_COMMENT: [
246 248 _('%(user)s commented on commit %(date_or_age)s'),
247 249 _('%(user)s commented on commit at %(date_or_age)s'),
248 250 ],
249 251 notification.TYPE_MESSAGE: [
250 252 _('%(user)s sent message %(date_or_age)s'),
251 253 _('%(user)s sent message at %(date_or_age)s'),
252 254 ],
253 255 notification.TYPE_MENTION: [
254 256 _('%(user)s mentioned you %(date_or_age)s'),
255 257 _('%(user)s mentioned you at %(date_or_age)s'),
256 258 ],
257 259 notification.TYPE_REGISTRATION: [
258 260 _('%(user)s registered in RhodeCode %(date_or_age)s'),
259 261 _('%(user)s registered in RhodeCode at %(date_or_age)s'),
260 262 ],
261 263 notification.TYPE_PULL_REQUEST: [
262 264 _('%(user)s opened new pull request %(date_or_age)s'),
263 265 _('%(user)s opened new pull request at %(date_or_age)s'),
264 266 ],
265 267 notification.TYPE_PULL_REQUEST_UPDATE: [
266 268 _('%(user)s updated pull request %(date_or_age)s'),
267 269 _('%(user)s updated pull request at %(date_or_age)s'),
268 270 ],
269 271 notification.TYPE_PULL_REQUEST_COMMENT: [
270 272 _('%(user)s commented on pull request %(date_or_age)s'),
271 273 _('%(user)s commented on pull request at %(date_or_age)s'),
272 274 ],
273 275 }
274 276
275 277 templates = _map[notification.type_]
276 278
277 279 if show_age:
278 280 template = templates[0]
279 281 date_or_age = h.age(notification.created_on)
280 282 if translate:
281 283 date_or_age = translate(date_or_age)
282 284
283 285 if isinstance(date_or_age, TranslationString):
284 286 date_or_age = date_or_age.interpolate()
285 287
286 288 else:
287 289 template = templates[1]
288 290 date_or_age = h.format_date(notification.created_on)
289 291
290 292 return template % {
291 293 'user': notification.created_by_user.username,
292 294 'date_or_age': date_or_age,
293 295 }
294 296
295 297
296 298 class EmailNotificationModel(BaseModel):
297 299 TYPE_COMMIT_COMMENT = Notification.TYPE_CHANGESET_COMMENT
298 300 TYPE_REGISTRATION = Notification.TYPE_REGISTRATION
299 301 TYPE_PULL_REQUEST = Notification.TYPE_PULL_REQUEST
300 302 TYPE_PULL_REQUEST_COMMENT = Notification.TYPE_PULL_REQUEST_COMMENT
301 303 TYPE_PULL_REQUEST_UPDATE = Notification.TYPE_PULL_REQUEST_UPDATE
302 304 TYPE_MAIN = Notification.TYPE_MESSAGE
303 305
304 306 TYPE_PASSWORD_RESET = 'password_reset'
305 307 TYPE_PASSWORD_RESET_CONFIRMATION = 'password_reset_confirmation'
306 308 TYPE_EMAIL_TEST = 'email_test'
307 309 TYPE_EMAIL_EXCEPTION = 'exception'
308 310 TYPE_TEST = 'test'
309 311
310 312 email_types = {
311 313 TYPE_MAIN:
312 314 'rhodecode:templates/email_templates/main.mako',
313 315 TYPE_TEST:
314 316 'rhodecode:templates/email_templates/test.mako',
315 317 TYPE_EMAIL_EXCEPTION:
316 318 'rhodecode:templates/email_templates/exception_tracker.mako',
317 319 TYPE_EMAIL_TEST:
318 320 'rhodecode:templates/email_templates/email_test.mako',
319 321 TYPE_REGISTRATION:
320 322 'rhodecode:templates/email_templates/user_registration.mako',
321 323 TYPE_PASSWORD_RESET:
322 324 'rhodecode:templates/email_templates/password_reset.mako',
323 325 TYPE_PASSWORD_RESET_CONFIRMATION:
324 326 'rhodecode:templates/email_templates/password_reset_confirmation.mako',
325 327 TYPE_COMMIT_COMMENT:
326 328 'rhodecode:templates/email_templates/commit_comment.mako',
327 329 TYPE_PULL_REQUEST:
328 330 'rhodecode:templates/email_templates/pull_request_review.mako',
329 331 TYPE_PULL_REQUEST_COMMENT:
330 332 'rhodecode:templates/email_templates/pull_request_comment.mako',
331 333 TYPE_PULL_REQUEST_UPDATE:
332 334 'rhodecode:templates/email_templates/pull_request_update.mako',
333 335 }
334 336
335 337 premailer_instance = premailer.Premailer(
336 338 cssutils_logging_level=logging.ERROR,
337 339 cssutils_logging_handler=logging.getLogger().handlers[0]
338 340 if logging.getLogger().handlers else None,
339 341 )
340 342
341 343 def __init__(self):
342 344 """
343 345 Example usage::
344 346
345 (subject, headers, email_body,
346 email_body_plaintext) = EmailNotificationModel().render_email(
347 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
347 348 EmailNotificationModel.TYPE_TEST, **email_kwargs)
348 349
349 350 """
350 351 super(EmailNotificationModel, self).__init__()
351 352 self.rhodecode_instance_name = rhodecode.CONFIG.get('rhodecode_title')
352 353
353 354 def _update_kwargs_for_render(self, kwargs):
354 355 """
355 356 Inject params required for Mako rendering
356 357
357 358 :param kwargs:
358 359 """
359 360
360 361 kwargs['rhodecode_instance_name'] = self.rhodecode_instance_name
361 362 kwargs['rhodecode_version'] = rhodecode.__version__
362 363 instance_url = h.route_url('home')
363 364 _kwargs = {
364 365 'instance_url': instance_url,
365 366 'whitespace_filter': self.whitespace_filter
366 367 }
367 368 _kwargs.update(kwargs)
368 369 return _kwargs
369 370
370 371 def whitespace_filter(self, text):
371 372 return text.replace('\n', '').replace('\t', '')
372 373
373 374 def get_renderer(self, type_, request):
374 375 template_name = self.email_types[type_]
375 376 return request.get_partial_renderer(template_name)
376 377
377 378 def render_email(self, type_, **kwargs):
378 379 """
379 380 renders template for email, and returns a tuple of
380 381 (subject, email_headers, email_html_body, email_plaintext_body)
381 382 """
382 383 # translator and helpers inject
383 384 _kwargs = self._update_kwargs_for_render(kwargs)
384 385 request = get_current_request()
385 386 email_template = self.get_renderer(type_, request=request)
386 387
387 388 subject = email_template.render('subject', **_kwargs)
388 389
389 390 try:
390 headers = email_template.render('headers', **_kwargs)
391 except AttributeError:
392 # it's not defined in template, ok we can skip it
393 headers = ''
394
395 try:
396 391 body_plaintext = email_template.render('body_plaintext', **_kwargs)
397 392 except AttributeError:
398 393 # it's not defined in template, ok we can skip it
399 394 body_plaintext = ''
400 395
401 396 # render WHOLE template
402 397 body = email_template.render(None, **_kwargs)
403 398
404 399 try:
405 400 # Inline CSS styles and conversion
406 401 body = self.premailer_instance.transform(body)
407 402 except Exception:
408 403 log.exception('Failed to parse body with premailer')
409 404 pass
410 405
411 return subject, headers, body, body_plaintext
406 return subject, body, body_plaintext
@@ -1,2074 +1,2072 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-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 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import os
30 30
31 31 import datetime
32 32 import urllib
33 33 import collections
34 34
35 35 from pyramid import compat
36 36 from pyramid.threadlocal import get_current_request
37 37
38 38 from rhodecode.lib.vcs.nodes import FileNode
39 39 from rhodecode.translation import lazy_ugettext
40 40 from rhodecode.lib import helpers as h, hooks_utils, diffs
41 41 from rhodecode.lib import audit_logger
42 42 from rhodecode.lib.compat import OrderedDict
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.markup_renderer import (
45 45 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
46 46 from rhodecode.lib.utils2 import (
47 47 safe_unicode, safe_str, md5_safe, AttributeDict, safe_int,
48 48 get_current_rhodecode_user)
49 49 from rhodecode.lib.vcs.backends.base import (
50 50 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason,
51 51 TargetRefMissing, SourceRefMissing)
52 52 from rhodecode.lib.vcs.conf import settings as vcs_settings
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 CommitDoesNotExistError, EmptyRepositoryError)
55 55 from rhodecode.model import BaseModel
56 56 from rhodecode.model.changeset_status import ChangesetStatusModel
57 57 from rhodecode.model.comment import CommentsModel
58 58 from rhodecode.model.db import (
59 59 or_, String, cast, PullRequest, PullRequestReviewers, ChangesetStatus,
60 60 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule, User)
61 61 from rhodecode.model.meta import Session
62 62 from rhodecode.model.notification import NotificationModel, \
63 63 EmailNotificationModel
64 64 from rhodecode.model.scm import ScmModel
65 65 from rhodecode.model.settings import VcsSettingsModel
66 66
67 67
68 68 log = logging.getLogger(__name__)
69 69
70 70
71 71 # Data structure to hold the response data when updating commits during a pull
72 72 # request update.
73 73 class UpdateResponse(object):
74 74
75 75 def __init__(self, executed, reason, new, old, common_ancestor_id,
76 76 commit_changes, source_changed, target_changed):
77 77
78 78 self.executed = executed
79 79 self.reason = reason
80 80 self.new = new
81 81 self.old = old
82 82 self.common_ancestor_id = common_ancestor_id
83 83 self.changes = commit_changes
84 84 self.source_changed = source_changed
85 85 self.target_changed = target_changed
86 86
87 87
88 88 def get_diff_info(
89 89 source_repo, source_ref, target_repo, target_ref, get_authors=False,
90 90 get_commit_authors=True):
91 91 """
92 92 Calculates detailed diff information for usage in preview of creation of a pull-request.
93 93 This is also used for default reviewers logic
94 94 """
95 95
96 96 source_scm = source_repo.scm_instance()
97 97 target_scm = target_repo.scm_instance()
98 98
99 99 ancestor_id = target_scm.get_common_ancestor(target_ref, source_ref, source_scm)
100 100 if not ancestor_id:
101 101 raise ValueError(
102 102 'cannot calculate diff info without a common ancestor. '
103 103 'Make sure both repositories are related, and have a common forking commit.')
104 104
105 105 # case here is that want a simple diff without incoming commits,
106 106 # previewing what will be merged based only on commits in the source.
107 107 log.debug('Using ancestor %s as source_ref instead of %s',
108 108 ancestor_id, source_ref)
109 109
110 110 # source of changes now is the common ancestor
111 111 source_commit = source_scm.get_commit(commit_id=ancestor_id)
112 112 # target commit becomes the source ref as it is the last commit
113 113 # for diff generation this logic gives proper diff
114 114 target_commit = source_scm.get_commit(commit_id=source_ref)
115 115
116 116 vcs_diff = \
117 117 source_scm.get_diff(commit1=source_commit, commit2=target_commit,
118 118 ignore_whitespace=False, context=3)
119 119
120 120 diff_processor = diffs.DiffProcessor(
121 121 vcs_diff, format='newdiff', diff_limit=None,
122 122 file_limit=None, show_full_diff=True)
123 123
124 124 _parsed = diff_processor.prepare()
125 125
126 126 all_files = []
127 127 all_files_changes = []
128 128 changed_lines = {}
129 129 stats = [0, 0]
130 130 for f in _parsed:
131 131 all_files.append(f['filename'])
132 132 all_files_changes.append({
133 133 'filename': f['filename'],
134 134 'stats': f['stats']
135 135 })
136 136 stats[0] += f['stats']['added']
137 137 stats[1] += f['stats']['deleted']
138 138
139 139 changed_lines[f['filename']] = []
140 140 if len(f['chunks']) < 2:
141 141 continue
142 142 # first line is "context" information
143 143 for chunks in f['chunks'][1:]:
144 144 for chunk in chunks['lines']:
145 145 if chunk['action'] not in ('del', 'mod'):
146 146 continue
147 147 changed_lines[f['filename']].append(chunk['old_lineno'])
148 148
149 149 commit_authors = []
150 150 user_counts = {}
151 151 email_counts = {}
152 152 author_counts = {}
153 153 _commit_cache = {}
154 154
155 155 commits = []
156 156 if get_commit_authors:
157 157 commits = target_scm.compare(
158 158 target_ref, source_ref, source_scm, merge=True,
159 159 pre_load=["author"])
160 160
161 161 for commit in commits:
162 162 user = User.get_from_cs_author(commit.author)
163 163 if user and user not in commit_authors:
164 164 commit_authors.append(user)
165 165
166 166 # lines
167 167 if get_authors:
168 168 target_commit = source_repo.get_commit(ancestor_id)
169 169
170 170 for fname, lines in changed_lines.items():
171 171 try:
172 172 node = target_commit.get_node(fname)
173 173 except Exception:
174 174 continue
175 175
176 176 if not isinstance(node, FileNode):
177 177 continue
178 178
179 179 for annotation in node.annotate:
180 180 line_no, commit_id, get_commit_func, line_text = annotation
181 181 if line_no in lines:
182 182 if commit_id not in _commit_cache:
183 183 _commit_cache[commit_id] = get_commit_func()
184 184 commit = _commit_cache[commit_id]
185 185 author = commit.author
186 186 email = commit.author_email
187 187 user = User.get_from_cs_author(author)
188 188 if user:
189 189 user_counts[user.user_id] = user_counts.get(user.user_id, 0) + 1
190 190 author_counts[author] = author_counts.get(author, 0) + 1
191 191 email_counts[email] = email_counts.get(email, 0) + 1
192 192
193 193 return {
194 194 'commits': commits,
195 195 'files': all_files_changes,
196 196 'stats': stats,
197 197 'ancestor': ancestor_id,
198 198 # original authors of modified files
199 199 'original_authors': {
200 200 'users': user_counts,
201 201 'authors': author_counts,
202 202 'emails': email_counts,
203 203 },
204 204 'commit_authors': commit_authors
205 205 }
206 206
207 207
208 208 class PullRequestModel(BaseModel):
209 209
210 210 cls = PullRequest
211 211
212 212 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
213 213
214 214 UPDATE_STATUS_MESSAGES = {
215 215 UpdateFailureReason.NONE: lazy_ugettext(
216 216 'Pull request update successful.'),
217 217 UpdateFailureReason.UNKNOWN: lazy_ugettext(
218 218 'Pull request update failed because of an unknown error.'),
219 219 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
220 220 'No update needed because the source and target have not changed.'),
221 221 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
222 222 'Pull request cannot be updated because the reference type is '
223 223 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
224 224 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
225 225 'This pull request cannot be updated because the target '
226 226 'reference is missing.'),
227 227 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
228 228 'This pull request cannot be updated because the source '
229 229 'reference is missing.'),
230 230 }
231 231 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
232 232 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
233 233
234 234 def __get_pull_request(self, pull_request):
235 235 return self._get_instance((
236 236 PullRequest, PullRequestVersion), pull_request)
237 237
238 238 def _check_perms(self, perms, pull_request, user, api=False):
239 239 if not api:
240 240 return h.HasRepoPermissionAny(*perms)(
241 241 user=user, repo_name=pull_request.target_repo.repo_name)
242 242 else:
243 243 return h.HasRepoPermissionAnyApi(*perms)(
244 244 user=user, repo_name=pull_request.target_repo.repo_name)
245 245
246 246 def check_user_read(self, pull_request, user, api=False):
247 247 _perms = ('repository.admin', 'repository.write', 'repository.read',)
248 248 return self._check_perms(_perms, pull_request, user, api)
249 249
250 250 def check_user_merge(self, pull_request, user, api=False):
251 251 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
252 252 return self._check_perms(_perms, pull_request, user, api)
253 253
254 254 def check_user_update(self, pull_request, user, api=False):
255 255 owner = user.user_id == pull_request.user_id
256 256 return self.check_user_merge(pull_request, user, api) or owner
257 257
258 258 def check_user_delete(self, pull_request, user):
259 259 owner = user.user_id == pull_request.user_id
260 260 _perms = ('repository.admin',)
261 261 return self._check_perms(_perms, pull_request, user) or owner
262 262
263 263 def check_user_change_status(self, pull_request, user, api=False):
264 264 reviewer = user.user_id in [x.user_id for x in
265 265 pull_request.reviewers]
266 266 return self.check_user_update(pull_request, user, api) or reviewer
267 267
268 268 def check_user_comment(self, pull_request, user):
269 269 owner = user.user_id == pull_request.user_id
270 270 return self.check_user_read(pull_request, user) or owner
271 271
272 272 def get(self, pull_request):
273 273 return self.__get_pull_request(pull_request)
274 274
275 275 def _prepare_get_all_query(self, repo_name, search_q=None, source=False,
276 276 statuses=None, opened_by=None, order_by=None,
277 277 order_dir='desc', only_created=False):
278 278 repo = None
279 279 if repo_name:
280 280 repo = self._get_repo(repo_name)
281 281
282 282 q = PullRequest.query()
283 283
284 284 if search_q:
285 285 like_expression = u'%{}%'.format(safe_unicode(search_q))
286 286 q = q.join(User)
287 287 q = q.filter(or_(
288 288 cast(PullRequest.pull_request_id, String).ilike(like_expression),
289 289 User.username.ilike(like_expression),
290 290 PullRequest.title.ilike(like_expression),
291 291 PullRequest.description.ilike(like_expression),
292 292 ))
293 293
294 294 # source or target
295 295 if repo and source:
296 296 q = q.filter(PullRequest.source_repo == repo)
297 297 elif repo:
298 298 q = q.filter(PullRequest.target_repo == repo)
299 299
300 300 # closed,opened
301 301 if statuses:
302 302 q = q.filter(PullRequest.status.in_(statuses))
303 303
304 304 # opened by filter
305 305 if opened_by:
306 306 q = q.filter(PullRequest.user_id.in_(opened_by))
307 307
308 308 # only get those that are in "created" state
309 309 if only_created:
310 310 q = q.filter(PullRequest.pull_request_state == PullRequest.STATE_CREATED)
311 311
312 312 if order_by:
313 313 order_map = {
314 314 'name_raw': PullRequest.pull_request_id,
315 315 'id': PullRequest.pull_request_id,
316 316 'title': PullRequest.title,
317 317 'updated_on_raw': PullRequest.updated_on,
318 318 'target_repo': PullRequest.target_repo_id
319 319 }
320 320 if order_dir == 'asc':
321 321 q = q.order_by(order_map[order_by].asc())
322 322 else:
323 323 q = q.order_by(order_map[order_by].desc())
324 324
325 325 return q
326 326
327 327 def count_all(self, repo_name, search_q=None, source=False, statuses=None,
328 328 opened_by=None):
329 329 """
330 330 Count the number of pull requests for a specific repository.
331 331
332 332 :param repo_name: target or source repo
333 333 :param search_q: filter by text
334 334 :param source: boolean flag to specify if repo_name refers to source
335 335 :param statuses: list of pull request statuses
336 336 :param opened_by: author user of the pull request
337 337 :returns: int number of pull requests
338 338 """
339 339 q = self._prepare_get_all_query(
340 340 repo_name, search_q=search_q, source=source, statuses=statuses,
341 341 opened_by=opened_by)
342 342
343 343 return q.count()
344 344
345 345 def get_all(self, repo_name, search_q=None, source=False, statuses=None,
346 346 opened_by=None, offset=0, length=None, order_by=None, order_dir='desc'):
347 347 """
348 348 Get all pull requests for a specific repository.
349 349
350 350 :param repo_name: target or source repo
351 351 :param search_q: filter by text
352 352 :param source: boolean flag to specify if repo_name refers to source
353 353 :param statuses: list of pull request statuses
354 354 :param opened_by: author user of the pull request
355 355 :param offset: pagination offset
356 356 :param length: length of returned list
357 357 :param order_by: order of the returned list
358 358 :param order_dir: 'asc' or 'desc' ordering direction
359 359 :returns: list of pull requests
360 360 """
361 361 q = self._prepare_get_all_query(
362 362 repo_name, search_q=search_q, source=source, statuses=statuses,
363 363 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
364 364
365 365 if length:
366 366 pull_requests = q.limit(length).offset(offset).all()
367 367 else:
368 368 pull_requests = q.all()
369 369
370 370 return pull_requests
371 371
372 372 def count_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
373 373 opened_by=None):
374 374 """
375 375 Count the number of pull requests for a specific repository that are
376 376 awaiting review.
377 377
378 378 :param repo_name: target or source repo
379 379 :param search_q: filter by text
380 380 :param source: boolean flag to specify if repo_name refers to source
381 381 :param statuses: list of pull request statuses
382 382 :param opened_by: author user of the pull request
383 383 :returns: int number of pull requests
384 384 """
385 385 pull_requests = self.get_awaiting_review(
386 386 repo_name, search_q=search_q, source=source, statuses=statuses, opened_by=opened_by)
387 387
388 388 return len(pull_requests)
389 389
390 390 def get_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
391 391 opened_by=None, offset=0, length=None,
392 392 order_by=None, order_dir='desc'):
393 393 """
394 394 Get all pull requests for a specific repository that are awaiting
395 395 review.
396 396
397 397 :param repo_name: target or source repo
398 398 :param search_q: filter by text
399 399 :param source: boolean flag to specify if repo_name refers to source
400 400 :param statuses: list of pull request statuses
401 401 :param opened_by: author user of the pull request
402 402 :param offset: pagination offset
403 403 :param length: length of returned list
404 404 :param order_by: order of the returned list
405 405 :param order_dir: 'asc' or 'desc' ordering direction
406 406 :returns: list of pull requests
407 407 """
408 408 pull_requests = self.get_all(
409 409 repo_name, search_q=search_q, source=source, statuses=statuses,
410 410 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
411 411
412 412 _filtered_pull_requests = []
413 413 for pr in pull_requests:
414 414 status = pr.calculated_review_status()
415 415 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
416 416 ChangesetStatus.STATUS_UNDER_REVIEW]:
417 417 _filtered_pull_requests.append(pr)
418 418 if length:
419 419 return _filtered_pull_requests[offset:offset+length]
420 420 else:
421 421 return _filtered_pull_requests
422 422
423 423 def count_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
424 424 opened_by=None, user_id=None):
425 425 """
426 426 Count the number of pull requests for a specific repository that are
427 427 awaiting review from a specific user.
428 428
429 429 :param repo_name: target or source repo
430 430 :param search_q: filter by text
431 431 :param source: boolean flag to specify if repo_name refers to source
432 432 :param statuses: list of pull request statuses
433 433 :param opened_by: author user of the pull request
434 434 :param user_id: reviewer user of the pull request
435 435 :returns: int number of pull requests
436 436 """
437 437 pull_requests = self.get_awaiting_my_review(
438 438 repo_name, search_q=search_q, source=source, statuses=statuses,
439 439 opened_by=opened_by, user_id=user_id)
440 440
441 441 return len(pull_requests)
442 442
443 443 def get_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
444 444 opened_by=None, user_id=None, offset=0,
445 445 length=None, order_by=None, order_dir='desc'):
446 446 """
447 447 Get all pull requests for a specific repository that are awaiting
448 448 review from a specific user.
449 449
450 450 :param repo_name: target or source repo
451 451 :param search_q: filter by text
452 452 :param source: boolean flag to specify if repo_name refers to source
453 453 :param statuses: list of pull request statuses
454 454 :param opened_by: author user of the pull request
455 455 :param user_id: reviewer user of the pull request
456 456 :param offset: pagination offset
457 457 :param length: length of returned list
458 458 :param order_by: order of the returned list
459 459 :param order_dir: 'asc' or 'desc' ordering direction
460 460 :returns: list of pull requests
461 461 """
462 462 pull_requests = self.get_all(
463 463 repo_name, search_q=search_q, source=source, statuses=statuses,
464 464 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
465 465
466 466 _my = PullRequestModel().get_not_reviewed(user_id)
467 467 my_participation = []
468 468 for pr in pull_requests:
469 469 if pr in _my:
470 470 my_participation.append(pr)
471 471 _filtered_pull_requests = my_participation
472 472 if length:
473 473 return _filtered_pull_requests[offset:offset+length]
474 474 else:
475 475 return _filtered_pull_requests
476 476
477 477 def get_not_reviewed(self, user_id):
478 478 return [
479 479 x.pull_request for x in PullRequestReviewers.query().filter(
480 480 PullRequestReviewers.user_id == user_id).all()
481 481 ]
482 482
483 483 def _prepare_participating_query(self, user_id=None, statuses=None, query='',
484 484 order_by=None, order_dir='desc'):
485 485 q = PullRequest.query()
486 486 if user_id:
487 487 reviewers_subquery = Session().query(
488 488 PullRequestReviewers.pull_request_id).filter(
489 489 PullRequestReviewers.user_id == user_id).subquery()
490 490 user_filter = or_(
491 491 PullRequest.user_id == user_id,
492 492 PullRequest.pull_request_id.in_(reviewers_subquery)
493 493 )
494 494 q = PullRequest.query().filter(user_filter)
495 495
496 496 # closed,opened
497 497 if statuses:
498 498 q = q.filter(PullRequest.status.in_(statuses))
499 499
500 500 if query:
501 501 like_expression = u'%{}%'.format(safe_unicode(query))
502 502 q = q.join(User)
503 503 q = q.filter(or_(
504 504 cast(PullRequest.pull_request_id, String).ilike(like_expression),
505 505 User.username.ilike(like_expression),
506 506 PullRequest.title.ilike(like_expression),
507 507 PullRequest.description.ilike(like_expression),
508 508 ))
509 509 if order_by:
510 510 order_map = {
511 511 'name_raw': PullRequest.pull_request_id,
512 512 'title': PullRequest.title,
513 513 'updated_on_raw': PullRequest.updated_on,
514 514 'target_repo': PullRequest.target_repo_id
515 515 }
516 516 if order_dir == 'asc':
517 517 q = q.order_by(order_map[order_by].asc())
518 518 else:
519 519 q = q.order_by(order_map[order_by].desc())
520 520
521 521 return q
522 522
523 523 def count_im_participating_in(self, user_id=None, statuses=None, query=''):
524 524 q = self._prepare_participating_query(user_id, statuses=statuses, query=query)
525 525 return q.count()
526 526
527 527 def get_im_participating_in(
528 528 self, user_id=None, statuses=None, query='', offset=0,
529 529 length=None, order_by=None, order_dir='desc'):
530 530 """
531 531 Get all Pull requests that i'm participating in, or i have opened
532 532 """
533 533
534 534 q = self._prepare_participating_query(
535 535 user_id, statuses=statuses, query=query, order_by=order_by,
536 536 order_dir=order_dir)
537 537
538 538 if length:
539 539 pull_requests = q.limit(length).offset(offset).all()
540 540 else:
541 541 pull_requests = q.all()
542 542
543 543 return pull_requests
544 544
545 545 def get_versions(self, pull_request):
546 546 """
547 547 returns version of pull request sorted by ID descending
548 548 """
549 549 return PullRequestVersion.query()\
550 550 .filter(PullRequestVersion.pull_request == pull_request)\
551 551 .order_by(PullRequestVersion.pull_request_version_id.asc())\
552 552 .all()
553 553
554 554 def get_pr_version(self, pull_request_id, version=None):
555 555 at_version = None
556 556
557 557 if version and version == 'latest':
558 558 pull_request_ver = PullRequest.get(pull_request_id)
559 559 pull_request_obj = pull_request_ver
560 560 _org_pull_request_obj = pull_request_obj
561 561 at_version = 'latest'
562 562 elif version:
563 563 pull_request_ver = PullRequestVersion.get_or_404(version)
564 564 pull_request_obj = pull_request_ver
565 565 _org_pull_request_obj = pull_request_ver.pull_request
566 566 at_version = pull_request_ver.pull_request_version_id
567 567 else:
568 568 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
569 569 pull_request_id)
570 570
571 571 pull_request_display_obj = PullRequest.get_pr_display_object(
572 572 pull_request_obj, _org_pull_request_obj)
573 573
574 574 return _org_pull_request_obj, pull_request_obj, \
575 575 pull_request_display_obj, at_version
576 576
577 577 def create(self, created_by, source_repo, source_ref, target_repo,
578 578 target_ref, revisions, reviewers, title, description=None,
579 579 common_ancestor_id=None,
580 580 description_renderer=None,
581 581 reviewer_data=None, translator=None, auth_user=None):
582 582 translator = translator or get_current_request().translate
583 583
584 584 created_by_user = self._get_user(created_by)
585 585 auth_user = auth_user or created_by_user.AuthUser()
586 586 source_repo = self._get_repo(source_repo)
587 587 target_repo = self._get_repo(target_repo)
588 588
589 589 pull_request = PullRequest()
590 590 pull_request.source_repo = source_repo
591 591 pull_request.source_ref = source_ref
592 592 pull_request.target_repo = target_repo
593 593 pull_request.target_ref = target_ref
594 594 pull_request.revisions = revisions
595 595 pull_request.title = title
596 596 pull_request.description = description
597 597 pull_request.description_renderer = description_renderer
598 598 pull_request.author = created_by_user
599 599 pull_request.reviewer_data = reviewer_data
600 600 pull_request.pull_request_state = pull_request.STATE_CREATING
601 601 pull_request.common_ancestor_id = common_ancestor_id
602 602
603 603 Session().add(pull_request)
604 604 Session().flush()
605 605
606 606 reviewer_ids = set()
607 607 # members / reviewers
608 608 for reviewer_object in reviewers:
609 609 user_id, reasons, mandatory, rules = reviewer_object
610 610 user = self._get_user(user_id)
611 611
612 612 # skip duplicates
613 613 if user.user_id in reviewer_ids:
614 614 continue
615 615
616 616 reviewer_ids.add(user.user_id)
617 617
618 618 reviewer = PullRequestReviewers()
619 619 reviewer.user = user
620 620 reviewer.pull_request = pull_request
621 621 reviewer.reasons = reasons
622 622 reviewer.mandatory = mandatory
623 623
624 624 # NOTE(marcink): pick only first rule for now
625 625 rule_id = list(rules)[0] if rules else None
626 626 rule = RepoReviewRule.get(rule_id) if rule_id else None
627 627 if rule:
628 628 review_group = rule.user_group_vote_rule(user_id)
629 629 # we check if this particular reviewer is member of a voting group
630 630 if review_group:
631 631 # NOTE(marcink):
632 632 # can be that user is member of more but we pick the first same,
633 633 # same as default reviewers algo
634 634 review_group = review_group[0]
635 635
636 636 rule_data = {
637 637 'rule_name':
638 638 rule.review_rule_name,
639 639 'rule_user_group_entry_id':
640 640 review_group.repo_review_rule_users_group_id,
641 641 'rule_user_group_name':
642 642 review_group.users_group.users_group_name,
643 643 'rule_user_group_members':
644 644 [x.user.username for x in review_group.users_group.members],
645 645 'rule_user_group_members_id':
646 646 [x.user.user_id for x in review_group.users_group.members],
647 647 }
648 648 # e.g {'vote_rule': -1, 'mandatory': True}
649 649 rule_data.update(review_group.rule_data())
650 650
651 651 reviewer.rule_data = rule_data
652 652
653 653 Session().add(reviewer)
654 654 Session().flush()
655 655
656 656 # Set approval status to "Under Review" for all commits which are
657 657 # part of this pull request.
658 658 ChangesetStatusModel().set_status(
659 659 repo=target_repo,
660 660 status=ChangesetStatus.STATUS_UNDER_REVIEW,
661 661 user=created_by_user,
662 662 pull_request=pull_request
663 663 )
664 664 # we commit early at this point. This has to do with a fact
665 665 # that before queries do some row-locking. And because of that
666 666 # we need to commit and finish transaction before below validate call
667 667 # that for large repos could be long resulting in long row locks
668 668 Session().commit()
669 669
670 670 # prepare workspace, and run initial merge simulation. Set state during that
671 671 # operation
672 672 pull_request = PullRequest.get(pull_request.pull_request_id)
673 673
674 674 # set as merging, for merge simulation, and if finished to created so we mark
675 675 # simulation is working fine
676 676 with pull_request.set_state(PullRequest.STATE_MERGING,
677 677 final_state=PullRequest.STATE_CREATED) as state_obj:
678 678 MergeCheck.validate(
679 679 pull_request, auth_user=auth_user, translator=translator)
680 680
681 681 self.notify_reviewers(pull_request, reviewer_ids)
682 682 self.trigger_pull_request_hook(pull_request, created_by_user, 'create')
683 683
684 684 creation_data = pull_request.get_api_data(with_merge_state=False)
685 685 self._log_audit_action(
686 686 'repo.pull_request.create', {'data': creation_data},
687 687 auth_user, pull_request)
688 688
689 689 return pull_request
690 690
691 691 def trigger_pull_request_hook(self, pull_request, user, action, data=None):
692 692 pull_request = self.__get_pull_request(pull_request)
693 693 target_scm = pull_request.target_repo.scm_instance()
694 694 if action == 'create':
695 695 trigger_hook = hooks_utils.trigger_create_pull_request_hook
696 696 elif action == 'merge':
697 697 trigger_hook = hooks_utils.trigger_merge_pull_request_hook
698 698 elif action == 'close':
699 699 trigger_hook = hooks_utils.trigger_close_pull_request_hook
700 700 elif action == 'review_status_change':
701 701 trigger_hook = hooks_utils.trigger_review_pull_request_hook
702 702 elif action == 'update':
703 703 trigger_hook = hooks_utils.trigger_update_pull_request_hook
704 704 elif action == 'comment':
705 705 trigger_hook = hooks_utils.trigger_comment_pull_request_hook
706 706 elif action == 'comment_edit':
707 707 trigger_hook = hooks_utils.trigger_comment_pull_request_edit_hook
708 708 else:
709 709 return
710 710
711 711 log.debug('Handling pull_request %s trigger_pull_request_hook with action %s and hook: %s',
712 712 pull_request, action, trigger_hook)
713 713 trigger_hook(
714 714 username=user.username,
715 715 repo_name=pull_request.target_repo.repo_name,
716 716 repo_type=target_scm.alias,
717 717 pull_request=pull_request,
718 718 data=data)
719 719
720 720 def _get_commit_ids(self, pull_request):
721 721 """
722 722 Return the commit ids of the merged pull request.
723 723
724 724 This method is not dealing correctly yet with the lack of autoupdates
725 725 nor with the implicit target updates.
726 726 For example: if a commit in the source repo is already in the target it
727 727 will be reported anyways.
728 728 """
729 729 merge_rev = pull_request.merge_rev
730 730 if merge_rev is None:
731 731 raise ValueError('This pull request was not merged yet')
732 732
733 733 commit_ids = list(pull_request.revisions)
734 734 if merge_rev not in commit_ids:
735 735 commit_ids.append(merge_rev)
736 736
737 737 return commit_ids
738 738
739 739 def merge_repo(self, pull_request, user, extras):
740 740 log.debug("Merging pull request %s", pull_request.pull_request_id)
741 741 extras['user_agent'] = 'internal-merge'
742 742 merge_state = self._merge_pull_request(pull_request, user, extras)
743 743 if merge_state.executed:
744 744 log.debug("Merge was successful, updating the pull request comments.")
745 745 self._comment_and_close_pr(pull_request, user, merge_state)
746 746
747 747 self._log_audit_action(
748 748 'repo.pull_request.merge',
749 749 {'merge_state': merge_state.__dict__},
750 750 user, pull_request)
751 751
752 752 else:
753 753 log.warn("Merge failed, not updating the pull request.")
754 754 return merge_state
755 755
756 756 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
757 757 target_vcs = pull_request.target_repo.scm_instance()
758 758 source_vcs = pull_request.source_repo.scm_instance()
759 759
760 760 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
761 761 pr_id=pull_request.pull_request_id,
762 762 pr_title=pull_request.title,
763 763 source_repo=source_vcs.name,
764 764 source_ref_name=pull_request.source_ref_parts.name,
765 765 target_repo=target_vcs.name,
766 766 target_ref_name=pull_request.target_ref_parts.name,
767 767 )
768 768
769 769 workspace_id = self._workspace_id(pull_request)
770 770 repo_id = pull_request.target_repo.repo_id
771 771 use_rebase = self._use_rebase_for_merging(pull_request)
772 772 close_branch = self._close_branch_before_merging(pull_request)
773 773 user_name = self._user_name_for_merging(pull_request, user)
774 774
775 775 target_ref = self._refresh_reference(
776 776 pull_request.target_ref_parts, target_vcs)
777 777
778 778 callback_daemon, extras = prepare_callback_daemon(
779 779 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
780 780 host=vcs_settings.HOOKS_HOST,
781 781 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
782 782
783 783 with callback_daemon:
784 784 # TODO: johbo: Implement a clean way to run a config_override
785 785 # for a single call.
786 786 target_vcs.config.set(
787 787 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
788 788
789 789 merge_state = target_vcs.merge(
790 790 repo_id, workspace_id, target_ref, source_vcs,
791 791 pull_request.source_ref_parts,
792 792 user_name=user_name, user_email=user.email,
793 793 message=message, use_rebase=use_rebase,
794 794 close_branch=close_branch)
795 795 return merge_state
796 796
797 797 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
798 798 pull_request.merge_rev = merge_state.merge_ref.commit_id
799 799 pull_request.updated_on = datetime.datetime.now()
800 800 close_msg = close_msg or 'Pull request merged and closed'
801 801
802 802 CommentsModel().create(
803 803 text=safe_unicode(close_msg),
804 804 repo=pull_request.target_repo.repo_id,
805 805 user=user.user_id,
806 806 pull_request=pull_request.pull_request_id,
807 807 f_path=None,
808 808 line_no=None,
809 809 closing_pr=True
810 810 )
811 811
812 812 Session().add(pull_request)
813 813 Session().flush()
814 814 # TODO: paris: replace invalidation with less radical solution
815 815 ScmModel().mark_for_invalidation(
816 816 pull_request.target_repo.repo_name)
817 817 self.trigger_pull_request_hook(pull_request, user, 'merge')
818 818
819 819 def has_valid_update_type(self, pull_request):
820 820 source_ref_type = pull_request.source_ref_parts.type
821 821 return source_ref_type in self.REF_TYPES
822 822
823 823 def get_flow_commits(self, pull_request):
824 824
825 825 # source repo
826 826 source_ref_name = pull_request.source_ref_parts.name
827 827 source_ref_type = pull_request.source_ref_parts.type
828 828 source_ref_id = pull_request.source_ref_parts.commit_id
829 829 source_repo = pull_request.source_repo.scm_instance()
830 830
831 831 try:
832 832 if source_ref_type in self.REF_TYPES:
833 833 source_commit = source_repo.get_commit(source_ref_name)
834 834 else:
835 835 source_commit = source_repo.get_commit(source_ref_id)
836 836 except CommitDoesNotExistError:
837 837 raise SourceRefMissing()
838 838
839 839 # target repo
840 840 target_ref_name = pull_request.target_ref_parts.name
841 841 target_ref_type = pull_request.target_ref_parts.type
842 842 target_ref_id = pull_request.target_ref_parts.commit_id
843 843 target_repo = pull_request.target_repo.scm_instance()
844 844
845 845 try:
846 846 if target_ref_type in self.REF_TYPES:
847 847 target_commit = target_repo.get_commit(target_ref_name)
848 848 else:
849 849 target_commit = target_repo.get_commit(target_ref_id)
850 850 except CommitDoesNotExistError:
851 851 raise TargetRefMissing()
852 852
853 853 return source_commit, target_commit
854 854
855 855 def update_commits(self, pull_request, updating_user):
856 856 """
857 857 Get the updated list of commits for the pull request
858 858 and return the new pull request version and the list
859 859 of commits processed by this update action
860 860
861 861 updating_user is the user_object who triggered the update
862 862 """
863 863 pull_request = self.__get_pull_request(pull_request)
864 864 source_ref_type = pull_request.source_ref_parts.type
865 865 source_ref_name = pull_request.source_ref_parts.name
866 866 source_ref_id = pull_request.source_ref_parts.commit_id
867 867
868 868 target_ref_type = pull_request.target_ref_parts.type
869 869 target_ref_name = pull_request.target_ref_parts.name
870 870 target_ref_id = pull_request.target_ref_parts.commit_id
871 871
872 872 if not self.has_valid_update_type(pull_request):
873 873 log.debug("Skipping update of pull request %s due to ref type: %s",
874 874 pull_request, source_ref_type)
875 875 return UpdateResponse(
876 876 executed=False,
877 877 reason=UpdateFailureReason.WRONG_REF_TYPE,
878 878 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
879 879 source_changed=False, target_changed=False)
880 880
881 881 try:
882 882 source_commit, target_commit = self.get_flow_commits(pull_request)
883 883 except SourceRefMissing:
884 884 return UpdateResponse(
885 885 executed=False,
886 886 reason=UpdateFailureReason.MISSING_SOURCE_REF,
887 887 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
888 888 source_changed=False, target_changed=False)
889 889 except TargetRefMissing:
890 890 return UpdateResponse(
891 891 executed=False,
892 892 reason=UpdateFailureReason.MISSING_TARGET_REF,
893 893 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
894 894 source_changed=False, target_changed=False)
895 895
896 896 source_changed = source_ref_id != source_commit.raw_id
897 897 target_changed = target_ref_id != target_commit.raw_id
898 898
899 899 if not (source_changed or target_changed):
900 900 log.debug("Nothing changed in pull request %s", pull_request)
901 901 return UpdateResponse(
902 902 executed=False,
903 903 reason=UpdateFailureReason.NO_CHANGE,
904 904 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
905 905 source_changed=target_changed, target_changed=source_changed)
906 906
907 907 change_in_found = 'target repo' if target_changed else 'source repo'
908 908 log.debug('Updating pull request because of change in %s detected',
909 909 change_in_found)
910 910
911 911 # Finally there is a need for an update, in case of source change
912 912 # we create a new version, else just an update
913 913 if source_changed:
914 914 pull_request_version = self._create_version_from_snapshot(pull_request)
915 915 self._link_comments_to_version(pull_request_version)
916 916 else:
917 917 try:
918 918 ver = pull_request.versions[-1]
919 919 except IndexError:
920 920 ver = None
921 921
922 922 pull_request.pull_request_version_id = \
923 923 ver.pull_request_version_id if ver else None
924 924 pull_request_version = pull_request
925 925
926 926 source_repo = pull_request.source_repo.scm_instance()
927 927 target_repo = pull_request.target_repo.scm_instance()
928 928
929 929 # re-compute commit ids
930 930 old_commit_ids = pull_request.revisions
931 931 pre_load = ["author", "date", "message", "branch"]
932 932 commit_ranges = target_repo.compare(
933 933 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
934 934 pre_load=pre_load)
935 935
936 936 target_ref = target_commit.raw_id
937 937 source_ref = source_commit.raw_id
938 938 ancestor_commit_id = target_repo.get_common_ancestor(
939 939 target_ref, source_ref, source_repo)
940 940
941 941 if not ancestor_commit_id:
942 942 raise ValueError(
943 943 'cannot calculate diff info without a common ancestor. '
944 944 'Make sure both repositories are related, and have a common forking commit.')
945 945
946 946 pull_request.common_ancestor_id = ancestor_commit_id
947 947
948 948 pull_request.source_ref = '%s:%s:%s' % (
949 949 source_ref_type, source_ref_name, source_commit.raw_id)
950 950 pull_request.target_ref = '%s:%s:%s' % (
951 951 target_ref_type, target_ref_name, ancestor_commit_id)
952 952
953 953 pull_request.revisions = [
954 954 commit.raw_id for commit in reversed(commit_ranges)]
955 955 pull_request.updated_on = datetime.datetime.now()
956 956 Session().add(pull_request)
957 957 new_commit_ids = pull_request.revisions
958 958
959 959 old_diff_data, new_diff_data = self._generate_update_diffs(
960 960 pull_request, pull_request_version)
961 961
962 962 # calculate commit and file changes
963 963 commit_changes = self._calculate_commit_id_changes(
964 964 old_commit_ids, new_commit_ids)
965 965 file_changes = self._calculate_file_changes(
966 966 old_diff_data, new_diff_data)
967 967
968 968 # set comments as outdated if DIFFS changed
969 969 CommentsModel().outdate_comments(
970 970 pull_request, old_diff_data=old_diff_data,
971 971 new_diff_data=new_diff_data)
972 972
973 973 valid_commit_changes = (commit_changes.added or commit_changes.removed)
974 974 file_node_changes = (
975 975 file_changes.added or file_changes.modified or file_changes.removed)
976 976 pr_has_changes = valid_commit_changes or file_node_changes
977 977
978 978 # Add an automatic comment to the pull request, in case
979 979 # anything has changed
980 980 if pr_has_changes:
981 981 update_comment = CommentsModel().create(
982 982 text=self._render_update_message(ancestor_commit_id, commit_changes, file_changes),
983 983 repo=pull_request.target_repo,
984 984 user=pull_request.author,
985 985 pull_request=pull_request,
986 986 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
987 987
988 988 # Update status to "Under Review" for added commits
989 989 for commit_id in commit_changes.added:
990 990 ChangesetStatusModel().set_status(
991 991 repo=pull_request.source_repo,
992 992 status=ChangesetStatus.STATUS_UNDER_REVIEW,
993 993 comment=update_comment,
994 994 user=pull_request.author,
995 995 pull_request=pull_request,
996 996 revision=commit_id)
997 997
998 998 # send update email to users
999 999 try:
1000 1000 self.notify_users(pull_request=pull_request, updating_user=updating_user,
1001 1001 ancestor_commit_id=ancestor_commit_id,
1002 1002 commit_changes=commit_changes,
1003 1003 file_changes=file_changes)
1004 1004 except Exception:
1005 1005 log.exception('Failed to send email notification to users')
1006 1006
1007 1007 log.debug(
1008 1008 'Updated pull request %s, added_ids: %s, common_ids: %s, '
1009 1009 'removed_ids: %s', pull_request.pull_request_id,
1010 1010 commit_changes.added, commit_changes.common, commit_changes.removed)
1011 1011 log.debug(
1012 1012 'Updated pull request with the following file changes: %s',
1013 1013 file_changes)
1014 1014
1015 1015 log.info(
1016 1016 "Updated pull request %s from commit %s to commit %s, "
1017 1017 "stored new version %s of this pull request.",
1018 1018 pull_request.pull_request_id, source_ref_id,
1019 1019 pull_request.source_ref_parts.commit_id,
1020 1020 pull_request_version.pull_request_version_id)
1021 1021 Session().commit()
1022 1022 self.trigger_pull_request_hook(pull_request, pull_request.author, 'update')
1023 1023
1024 1024 return UpdateResponse(
1025 1025 executed=True, reason=UpdateFailureReason.NONE,
1026 1026 old=pull_request, new=pull_request_version,
1027 1027 common_ancestor_id=ancestor_commit_id, commit_changes=commit_changes,
1028 1028 source_changed=source_changed, target_changed=target_changed)
1029 1029
1030 1030 def _create_version_from_snapshot(self, pull_request):
1031 1031 version = PullRequestVersion()
1032 1032 version.title = pull_request.title
1033 1033 version.description = pull_request.description
1034 1034 version.status = pull_request.status
1035 1035 version.pull_request_state = pull_request.pull_request_state
1036 1036 version.created_on = datetime.datetime.now()
1037 1037 version.updated_on = pull_request.updated_on
1038 1038 version.user_id = pull_request.user_id
1039 1039 version.source_repo = pull_request.source_repo
1040 1040 version.source_ref = pull_request.source_ref
1041 1041 version.target_repo = pull_request.target_repo
1042 1042 version.target_ref = pull_request.target_ref
1043 1043
1044 1044 version._last_merge_source_rev = pull_request._last_merge_source_rev
1045 1045 version._last_merge_target_rev = pull_request._last_merge_target_rev
1046 1046 version.last_merge_status = pull_request.last_merge_status
1047 1047 version.last_merge_metadata = pull_request.last_merge_metadata
1048 1048 version.shadow_merge_ref = pull_request.shadow_merge_ref
1049 1049 version.merge_rev = pull_request.merge_rev
1050 1050 version.reviewer_data = pull_request.reviewer_data
1051 1051
1052 1052 version.revisions = pull_request.revisions
1053 1053 version.common_ancestor_id = pull_request.common_ancestor_id
1054 1054 version.pull_request = pull_request
1055 1055 Session().add(version)
1056 1056 Session().flush()
1057 1057
1058 1058 return version
1059 1059
1060 1060 def _generate_update_diffs(self, pull_request, pull_request_version):
1061 1061
1062 1062 diff_context = (
1063 1063 self.DIFF_CONTEXT +
1064 1064 CommentsModel.needed_extra_diff_context())
1065 1065 hide_whitespace_changes = False
1066 1066 source_repo = pull_request_version.source_repo
1067 1067 source_ref_id = pull_request_version.source_ref_parts.commit_id
1068 1068 target_ref_id = pull_request_version.target_ref_parts.commit_id
1069 1069 old_diff = self._get_diff_from_pr_or_version(
1070 1070 source_repo, source_ref_id, target_ref_id,
1071 1071 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1072 1072
1073 1073 source_repo = pull_request.source_repo
1074 1074 source_ref_id = pull_request.source_ref_parts.commit_id
1075 1075 target_ref_id = pull_request.target_ref_parts.commit_id
1076 1076
1077 1077 new_diff = self._get_diff_from_pr_or_version(
1078 1078 source_repo, source_ref_id, target_ref_id,
1079 1079 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1080 1080
1081 1081 old_diff_data = diffs.DiffProcessor(old_diff)
1082 1082 old_diff_data.prepare()
1083 1083 new_diff_data = diffs.DiffProcessor(new_diff)
1084 1084 new_diff_data.prepare()
1085 1085
1086 1086 return old_diff_data, new_diff_data
1087 1087
1088 1088 def _link_comments_to_version(self, pull_request_version):
1089 1089 """
1090 1090 Link all unlinked comments of this pull request to the given version.
1091 1091
1092 1092 :param pull_request_version: The `PullRequestVersion` to which
1093 1093 the comments shall be linked.
1094 1094
1095 1095 """
1096 1096 pull_request = pull_request_version.pull_request
1097 1097 comments = ChangesetComment.query()\
1098 1098 .filter(
1099 1099 # TODO: johbo: Should we query for the repo at all here?
1100 1100 # Pending decision on how comments of PRs are to be related
1101 1101 # to either the source repo, the target repo or no repo at all.
1102 1102 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
1103 1103 ChangesetComment.pull_request == pull_request,
1104 1104 ChangesetComment.pull_request_version == None)\
1105 1105 .order_by(ChangesetComment.comment_id.asc())
1106 1106
1107 1107 # TODO: johbo: Find out why this breaks if it is done in a bulk
1108 1108 # operation.
1109 1109 for comment in comments:
1110 1110 comment.pull_request_version_id = (
1111 1111 pull_request_version.pull_request_version_id)
1112 1112 Session().add(comment)
1113 1113
1114 1114 def _calculate_commit_id_changes(self, old_ids, new_ids):
1115 1115 added = [x for x in new_ids if x not in old_ids]
1116 1116 common = [x for x in new_ids if x in old_ids]
1117 1117 removed = [x for x in old_ids if x not in new_ids]
1118 1118 total = new_ids
1119 1119 return ChangeTuple(added, common, removed, total)
1120 1120
1121 1121 def _calculate_file_changes(self, old_diff_data, new_diff_data):
1122 1122
1123 1123 old_files = OrderedDict()
1124 1124 for diff_data in old_diff_data.parsed_diff:
1125 1125 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
1126 1126
1127 1127 added_files = []
1128 1128 modified_files = []
1129 1129 removed_files = []
1130 1130 for diff_data in new_diff_data.parsed_diff:
1131 1131 new_filename = diff_data['filename']
1132 1132 new_hash = md5_safe(diff_data['raw_diff'])
1133 1133
1134 1134 old_hash = old_files.get(new_filename)
1135 1135 if not old_hash:
1136 1136 # file is not present in old diff, we have to figure out from parsed diff
1137 1137 # operation ADD/REMOVE
1138 1138 operations_dict = diff_data['stats']['ops']
1139 1139 if diffs.DEL_FILENODE in operations_dict:
1140 1140 removed_files.append(new_filename)
1141 1141 else:
1142 1142 added_files.append(new_filename)
1143 1143 else:
1144 1144 if new_hash != old_hash:
1145 1145 modified_files.append(new_filename)
1146 1146 # now remove a file from old, since we have seen it already
1147 1147 del old_files[new_filename]
1148 1148
1149 1149 # removed files is when there are present in old, but not in NEW,
1150 1150 # since we remove old files that are present in new diff, left-overs
1151 1151 # if any should be the removed files
1152 1152 removed_files.extend(old_files.keys())
1153 1153
1154 1154 return FileChangeTuple(added_files, modified_files, removed_files)
1155 1155
1156 1156 def _render_update_message(self, ancestor_commit_id, changes, file_changes):
1157 1157 """
1158 1158 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
1159 1159 so it's always looking the same disregarding on which default
1160 1160 renderer system is using.
1161 1161
1162 1162 :param ancestor_commit_id: ancestor raw_id
1163 1163 :param changes: changes named tuple
1164 1164 :param file_changes: file changes named tuple
1165 1165
1166 1166 """
1167 1167 new_status = ChangesetStatus.get_status_lbl(
1168 1168 ChangesetStatus.STATUS_UNDER_REVIEW)
1169 1169
1170 1170 changed_files = (
1171 1171 file_changes.added + file_changes.modified + file_changes.removed)
1172 1172
1173 1173 params = {
1174 1174 'under_review_label': new_status,
1175 1175 'added_commits': changes.added,
1176 1176 'removed_commits': changes.removed,
1177 1177 'changed_files': changed_files,
1178 1178 'added_files': file_changes.added,
1179 1179 'modified_files': file_changes.modified,
1180 1180 'removed_files': file_changes.removed,
1181 1181 'ancestor_commit_id': ancestor_commit_id
1182 1182 }
1183 1183 renderer = RstTemplateRenderer()
1184 1184 return renderer.render('pull_request_update.mako', **params)
1185 1185
1186 1186 def edit(self, pull_request, title, description, description_renderer, user):
1187 1187 pull_request = self.__get_pull_request(pull_request)
1188 1188 old_data = pull_request.get_api_data(with_merge_state=False)
1189 1189 if pull_request.is_closed():
1190 1190 raise ValueError('This pull request is closed')
1191 1191 if title:
1192 1192 pull_request.title = title
1193 1193 pull_request.description = description
1194 1194 pull_request.updated_on = datetime.datetime.now()
1195 1195 pull_request.description_renderer = description_renderer
1196 1196 Session().add(pull_request)
1197 1197 self._log_audit_action(
1198 1198 'repo.pull_request.edit', {'old_data': old_data},
1199 1199 user, pull_request)
1200 1200
1201 1201 def update_reviewers(self, pull_request, reviewer_data, user):
1202 1202 """
1203 1203 Update the reviewers in the pull request
1204 1204
1205 1205 :param pull_request: the pr to update
1206 1206 :param reviewer_data: list of tuples
1207 1207 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
1208 1208 """
1209 1209 pull_request = self.__get_pull_request(pull_request)
1210 1210 if pull_request.is_closed():
1211 1211 raise ValueError('This pull request is closed')
1212 1212
1213 1213 reviewers = {}
1214 1214 for user_id, reasons, mandatory, rules in reviewer_data:
1215 1215 if isinstance(user_id, (int, compat.string_types)):
1216 1216 user_id = self._get_user(user_id).user_id
1217 1217 reviewers[user_id] = {
1218 1218 'reasons': reasons, 'mandatory': mandatory}
1219 1219
1220 1220 reviewers_ids = set(reviewers.keys())
1221 1221 current_reviewers = PullRequestReviewers.query()\
1222 1222 .filter(PullRequestReviewers.pull_request ==
1223 1223 pull_request).all()
1224 1224 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1225 1225
1226 1226 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1227 1227 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1228 1228
1229 1229 log.debug("Adding %s reviewers", ids_to_add)
1230 1230 log.debug("Removing %s reviewers", ids_to_remove)
1231 1231 changed = False
1232 1232 added_audit_reviewers = []
1233 1233 removed_audit_reviewers = []
1234 1234
1235 1235 for uid in ids_to_add:
1236 1236 changed = True
1237 1237 _usr = self._get_user(uid)
1238 1238 reviewer = PullRequestReviewers()
1239 1239 reviewer.user = _usr
1240 1240 reviewer.pull_request = pull_request
1241 1241 reviewer.reasons = reviewers[uid]['reasons']
1242 1242 # NOTE(marcink): mandatory shouldn't be changed now
1243 1243 # reviewer.mandatory = reviewers[uid]['reasons']
1244 1244 Session().add(reviewer)
1245 1245 added_audit_reviewers.append(reviewer.get_dict())
1246 1246
1247 1247 for uid in ids_to_remove:
1248 1248 changed = True
1249 1249 # NOTE(marcink): we fetch "ALL" reviewers using .all(). This is an edge case
1250 1250 # that prevents and fixes cases that we added the same reviewer twice.
1251 1251 # this CAN happen due to the lack of DB checks
1252 1252 reviewers = PullRequestReviewers.query()\
1253 1253 .filter(PullRequestReviewers.user_id == uid,
1254 1254 PullRequestReviewers.pull_request == pull_request)\
1255 1255 .all()
1256 1256
1257 1257 for obj in reviewers:
1258 1258 added_audit_reviewers.append(obj.get_dict())
1259 1259 Session().delete(obj)
1260 1260
1261 1261 if changed:
1262 1262 Session().expire_all()
1263 1263 pull_request.updated_on = datetime.datetime.now()
1264 1264 Session().add(pull_request)
1265 1265
1266 1266 # finally store audit logs
1267 1267 for user_data in added_audit_reviewers:
1268 1268 self._log_audit_action(
1269 1269 'repo.pull_request.reviewer.add', {'data': user_data},
1270 1270 user, pull_request)
1271 1271 for user_data in removed_audit_reviewers:
1272 1272 self._log_audit_action(
1273 1273 'repo.pull_request.reviewer.delete', {'old_data': user_data},
1274 1274 user, pull_request)
1275 1275
1276 1276 self.notify_reviewers(pull_request, ids_to_add)
1277 1277 return ids_to_add, ids_to_remove
1278 1278
1279 1279 def get_url(self, pull_request, request=None, permalink=False):
1280 1280 if not request:
1281 1281 request = get_current_request()
1282 1282
1283 1283 if permalink:
1284 1284 return request.route_url(
1285 1285 'pull_requests_global',
1286 1286 pull_request_id=pull_request.pull_request_id,)
1287 1287 else:
1288 1288 return request.route_url('pullrequest_show',
1289 1289 repo_name=safe_str(pull_request.target_repo.repo_name),
1290 1290 pull_request_id=pull_request.pull_request_id,)
1291 1291
1292 1292 def get_shadow_clone_url(self, pull_request, request=None):
1293 1293 """
1294 1294 Returns qualified url pointing to the shadow repository. If this pull
1295 1295 request is closed there is no shadow repository and ``None`` will be
1296 1296 returned.
1297 1297 """
1298 1298 if pull_request.is_closed():
1299 1299 return None
1300 1300 else:
1301 1301 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1302 1302 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1303 1303
1304 1304 def notify_reviewers(self, pull_request, reviewers_ids):
1305 1305 # notification to reviewers
1306 1306 if not reviewers_ids:
1307 1307 return
1308 1308
1309 1309 log.debug('Notify following reviewers about pull-request %s', reviewers_ids)
1310 1310
1311 1311 pull_request_obj = pull_request
1312 1312 # get the current participants of this pull request
1313 1313 recipients = reviewers_ids
1314 1314 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1315 1315
1316 1316 pr_source_repo = pull_request_obj.source_repo
1317 1317 pr_target_repo = pull_request_obj.target_repo
1318 1318
1319 1319 pr_url = h.route_url('pullrequest_show',
1320 1320 repo_name=pr_target_repo.repo_name,
1321 1321 pull_request_id=pull_request_obj.pull_request_id,)
1322 1322
1323 1323 # set some variables for email notification
1324 1324 pr_target_repo_url = h.route_url(
1325 1325 'repo_summary', repo_name=pr_target_repo.repo_name)
1326 1326
1327 1327 pr_source_repo_url = h.route_url(
1328 1328 'repo_summary', repo_name=pr_source_repo.repo_name)
1329 1329
1330 1330 # pull request specifics
1331 1331 pull_request_commits = [
1332 1332 (x.raw_id, x.message)
1333 1333 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1334 1334
1335 1335 kwargs = {
1336 1336 'user': pull_request.author,
1337 1337 'pull_request': pull_request_obj,
1338 1338 'pull_request_commits': pull_request_commits,
1339 1339
1340 1340 'pull_request_target_repo': pr_target_repo,
1341 1341 'pull_request_target_repo_url': pr_target_repo_url,
1342 1342
1343 1343 'pull_request_source_repo': pr_source_repo,
1344 1344 'pull_request_source_repo_url': pr_source_repo_url,
1345 1345
1346 1346 'pull_request_url': pr_url,
1347 'thread_ids': [pr_url],
1347 1348 }
1348 1349
1349 1350 # pre-generate the subject for notification itself
1350 (subject,
1351 _h, _e, # we don't care about those
1352 body_plaintext) = EmailNotificationModel().render_email(
1351 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
1353 1352 notification_type, **kwargs)
1354 1353
1355 1354 # create notification objects, and emails
1356 1355 NotificationModel().create(
1357 1356 created_by=pull_request.author,
1358 1357 notification_subject=subject,
1359 1358 notification_body=body_plaintext,
1360 1359 notification_type=notification_type,
1361 1360 recipients=recipients,
1362 1361 email_kwargs=kwargs,
1363 1362 )
1364 1363
1365 1364 def notify_users(self, pull_request, updating_user, ancestor_commit_id,
1366 1365 commit_changes, file_changes):
1367 1366
1368 1367 updating_user_id = updating_user.user_id
1369 1368 reviewers = set([x.user.user_id for x in pull_request.reviewers])
1370 1369 # NOTE(marcink): send notification to all other users except to
1371 1370 # person who updated the PR
1372 1371 recipients = reviewers.difference(set([updating_user_id]))
1373 1372
1374 1373 log.debug('Notify following recipients about pull-request update %s', recipients)
1375 1374
1376 1375 pull_request_obj = pull_request
1377 1376
1378 1377 # send email about the update
1379 1378 changed_files = (
1380 1379 file_changes.added + file_changes.modified + file_changes.removed)
1381 1380
1382 1381 pr_source_repo = pull_request_obj.source_repo
1383 1382 pr_target_repo = pull_request_obj.target_repo
1384 1383
1385 1384 pr_url = h.route_url('pullrequest_show',
1386 1385 repo_name=pr_target_repo.repo_name,
1387 1386 pull_request_id=pull_request_obj.pull_request_id,)
1388 1387
1389 1388 # set some variables for email notification
1390 1389 pr_target_repo_url = h.route_url(
1391 1390 'repo_summary', repo_name=pr_target_repo.repo_name)
1392 1391
1393 1392 pr_source_repo_url = h.route_url(
1394 1393 'repo_summary', repo_name=pr_source_repo.repo_name)
1395 1394
1396 1395 email_kwargs = {
1397 1396 'date': datetime.datetime.now(),
1398 1397 'updating_user': updating_user,
1399 1398
1400 1399 'pull_request': pull_request_obj,
1401 1400
1402 1401 'pull_request_target_repo': pr_target_repo,
1403 1402 'pull_request_target_repo_url': pr_target_repo_url,
1404 1403
1405 1404 'pull_request_source_repo': pr_source_repo,
1406 1405 'pull_request_source_repo_url': pr_source_repo_url,
1407 1406
1408 1407 'pull_request_url': pr_url,
1409 1408
1410 1409 'ancestor_commit_id': ancestor_commit_id,
1411 1410 'added_commits': commit_changes.added,
1412 1411 'removed_commits': commit_changes.removed,
1413 1412 'changed_files': changed_files,
1414 1413 'added_files': file_changes.added,
1415 1414 'modified_files': file_changes.modified,
1416 1415 'removed_files': file_changes.removed,
1416 'thread_ids': [pr_url],
1417 1417 }
1418 1418
1419 (subject,
1420 _h, _e, # we don't care about those
1421 body_plaintext) = EmailNotificationModel().render_email(
1419 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
1422 1420 EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE, **email_kwargs)
1423 1421
1424 1422 # create notification objects, and emails
1425 1423 NotificationModel().create(
1426 1424 created_by=updating_user,
1427 1425 notification_subject=subject,
1428 1426 notification_body=body_plaintext,
1429 1427 notification_type=EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE,
1430 1428 recipients=recipients,
1431 1429 email_kwargs=email_kwargs,
1432 1430 )
1433 1431
1434 1432 def delete(self, pull_request, user=None):
1435 1433 if not user:
1436 1434 user = getattr(get_current_rhodecode_user(), 'username', None)
1437 1435
1438 1436 pull_request = self.__get_pull_request(pull_request)
1439 1437 old_data = pull_request.get_api_data(with_merge_state=False)
1440 1438 self._cleanup_merge_workspace(pull_request)
1441 1439 self._log_audit_action(
1442 1440 'repo.pull_request.delete', {'old_data': old_data},
1443 1441 user, pull_request)
1444 1442 Session().delete(pull_request)
1445 1443
1446 1444 def close_pull_request(self, pull_request, user):
1447 1445 pull_request = self.__get_pull_request(pull_request)
1448 1446 self._cleanup_merge_workspace(pull_request)
1449 1447 pull_request.status = PullRequest.STATUS_CLOSED
1450 1448 pull_request.updated_on = datetime.datetime.now()
1451 1449 Session().add(pull_request)
1452 1450 self.trigger_pull_request_hook(pull_request, pull_request.author, 'close')
1453 1451
1454 1452 pr_data = pull_request.get_api_data(with_merge_state=False)
1455 1453 self._log_audit_action(
1456 1454 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1457 1455
1458 1456 def close_pull_request_with_comment(
1459 1457 self, pull_request, user, repo, message=None, auth_user=None):
1460 1458
1461 1459 pull_request_review_status = pull_request.calculated_review_status()
1462 1460
1463 1461 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1464 1462 # approved only if we have voting consent
1465 1463 status = ChangesetStatus.STATUS_APPROVED
1466 1464 else:
1467 1465 status = ChangesetStatus.STATUS_REJECTED
1468 1466 status_lbl = ChangesetStatus.get_status_lbl(status)
1469 1467
1470 1468 default_message = (
1471 1469 'Closing with status change {transition_icon} {status}.'
1472 1470 ).format(transition_icon='>', status=status_lbl)
1473 1471 text = message or default_message
1474 1472
1475 1473 # create a comment, and link it to new status
1476 1474 comment = CommentsModel().create(
1477 1475 text=text,
1478 1476 repo=repo.repo_id,
1479 1477 user=user.user_id,
1480 1478 pull_request=pull_request.pull_request_id,
1481 1479 status_change=status_lbl,
1482 1480 status_change_type=status,
1483 1481 closing_pr=True,
1484 1482 auth_user=auth_user,
1485 1483 )
1486 1484
1487 1485 # calculate old status before we change it
1488 1486 old_calculated_status = pull_request.calculated_review_status()
1489 1487 ChangesetStatusModel().set_status(
1490 1488 repo.repo_id,
1491 1489 status,
1492 1490 user.user_id,
1493 1491 comment=comment,
1494 1492 pull_request=pull_request.pull_request_id
1495 1493 )
1496 1494
1497 1495 Session().flush()
1498 1496
1499 1497 self.trigger_pull_request_hook(pull_request, user, 'comment',
1500 1498 data={'comment': comment})
1501 1499
1502 1500 # we now calculate the status of pull request again, and based on that
1503 1501 # calculation trigger status change. This might happen in cases
1504 1502 # that non-reviewer admin closes a pr, which means his vote doesn't
1505 1503 # change the status, while if he's a reviewer this might change it.
1506 1504 calculated_status = pull_request.calculated_review_status()
1507 1505 if old_calculated_status != calculated_status:
1508 1506 self.trigger_pull_request_hook(pull_request, user, 'review_status_change',
1509 1507 data={'status': calculated_status})
1510 1508
1511 1509 # finally close the PR
1512 1510 PullRequestModel().close_pull_request(pull_request.pull_request_id, user)
1513 1511
1514 1512 return comment, status
1515 1513
1516 1514 def merge_status(self, pull_request, translator=None, force_shadow_repo_refresh=False):
1517 1515 _ = translator or get_current_request().translate
1518 1516
1519 1517 if not self._is_merge_enabled(pull_request):
1520 1518 return None, False, _('Server-side pull request merging is disabled.')
1521 1519
1522 1520 if pull_request.is_closed():
1523 1521 return None, False, _('This pull request is closed.')
1524 1522
1525 1523 merge_possible, msg = self._check_repo_requirements(
1526 1524 target=pull_request.target_repo, source=pull_request.source_repo,
1527 1525 translator=_)
1528 1526 if not merge_possible:
1529 1527 return None, merge_possible, msg
1530 1528
1531 1529 try:
1532 1530 merge_response = self._try_merge(
1533 1531 pull_request, force_shadow_repo_refresh=force_shadow_repo_refresh)
1534 1532 log.debug("Merge response: %s", merge_response)
1535 1533 return merge_response, merge_response.possible, merge_response.merge_status_message
1536 1534 except NotImplementedError:
1537 1535 return None, False, _('Pull request merging is not supported.')
1538 1536
1539 1537 def _check_repo_requirements(self, target, source, translator):
1540 1538 """
1541 1539 Check if `target` and `source` have compatible requirements.
1542 1540
1543 1541 Currently this is just checking for largefiles.
1544 1542 """
1545 1543 _ = translator
1546 1544 target_has_largefiles = self._has_largefiles(target)
1547 1545 source_has_largefiles = self._has_largefiles(source)
1548 1546 merge_possible = True
1549 1547 message = u''
1550 1548
1551 1549 if target_has_largefiles != source_has_largefiles:
1552 1550 merge_possible = False
1553 1551 if source_has_largefiles:
1554 1552 message = _(
1555 1553 'Target repository large files support is disabled.')
1556 1554 else:
1557 1555 message = _(
1558 1556 'Source repository large files support is disabled.')
1559 1557
1560 1558 return merge_possible, message
1561 1559
1562 1560 def _has_largefiles(self, repo):
1563 1561 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1564 1562 'extensions', 'largefiles')
1565 1563 return largefiles_ui and largefiles_ui[0].active
1566 1564
1567 1565 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1568 1566 """
1569 1567 Try to merge the pull request and return the merge status.
1570 1568 """
1571 1569 log.debug(
1572 1570 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1573 1571 pull_request.pull_request_id, force_shadow_repo_refresh)
1574 1572 target_vcs = pull_request.target_repo.scm_instance()
1575 1573 # Refresh the target reference.
1576 1574 try:
1577 1575 target_ref = self._refresh_reference(
1578 1576 pull_request.target_ref_parts, target_vcs)
1579 1577 except CommitDoesNotExistError:
1580 1578 merge_state = MergeResponse(
1581 1579 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1582 1580 metadata={'target_ref': pull_request.target_ref_parts})
1583 1581 return merge_state
1584 1582
1585 1583 target_locked = pull_request.target_repo.locked
1586 1584 if target_locked and target_locked[0]:
1587 1585 locked_by = 'user:{}'.format(target_locked[0])
1588 1586 log.debug("The target repository is locked by %s.", locked_by)
1589 1587 merge_state = MergeResponse(
1590 1588 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1591 1589 metadata={'locked_by': locked_by})
1592 1590 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1593 1591 pull_request, target_ref):
1594 1592 log.debug("Refreshing the merge status of the repository.")
1595 1593 merge_state = self._refresh_merge_state(
1596 1594 pull_request, target_vcs, target_ref)
1597 1595 else:
1598 1596 possible = pull_request.last_merge_status == MergeFailureReason.NONE
1599 1597 metadata = {
1600 1598 'unresolved_files': '',
1601 1599 'target_ref': pull_request.target_ref_parts,
1602 1600 'source_ref': pull_request.source_ref_parts,
1603 1601 }
1604 1602 if pull_request.last_merge_metadata:
1605 1603 metadata.update(pull_request.last_merge_metadata)
1606 1604
1607 1605 if not possible and target_ref.type == 'branch':
1608 1606 # NOTE(marcink): case for mercurial multiple heads on branch
1609 1607 heads = target_vcs._heads(target_ref.name)
1610 1608 if len(heads) != 1:
1611 1609 heads = '\n,'.join(target_vcs._heads(target_ref.name))
1612 1610 metadata.update({
1613 1611 'heads': heads
1614 1612 })
1615 1613
1616 1614 merge_state = MergeResponse(
1617 1615 possible, False, None, pull_request.last_merge_status, metadata=metadata)
1618 1616
1619 1617 return merge_state
1620 1618
1621 1619 def _refresh_reference(self, reference, vcs_repository):
1622 1620 if reference.type in self.UPDATABLE_REF_TYPES:
1623 1621 name_or_id = reference.name
1624 1622 else:
1625 1623 name_or_id = reference.commit_id
1626 1624
1627 1625 refreshed_commit = vcs_repository.get_commit(name_or_id)
1628 1626 refreshed_reference = Reference(
1629 1627 reference.type, reference.name, refreshed_commit.raw_id)
1630 1628 return refreshed_reference
1631 1629
1632 1630 def _needs_merge_state_refresh(self, pull_request, target_reference):
1633 1631 return not(
1634 1632 pull_request.revisions and
1635 1633 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1636 1634 target_reference.commit_id == pull_request._last_merge_target_rev)
1637 1635
1638 1636 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1639 1637 workspace_id = self._workspace_id(pull_request)
1640 1638 source_vcs = pull_request.source_repo.scm_instance()
1641 1639 repo_id = pull_request.target_repo.repo_id
1642 1640 use_rebase = self._use_rebase_for_merging(pull_request)
1643 1641 close_branch = self._close_branch_before_merging(pull_request)
1644 1642 merge_state = target_vcs.merge(
1645 1643 repo_id, workspace_id,
1646 1644 target_reference, source_vcs, pull_request.source_ref_parts,
1647 1645 dry_run=True, use_rebase=use_rebase,
1648 1646 close_branch=close_branch)
1649 1647
1650 1648 # Do not store the response if there was an unknown error.
1651 1649 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1652 1650 pull_request._last_merge_source_rev = \
1653 1651 pull_request.source_ref_parts.commit_id
1654 1652 pull_request._last_merge_target_rev = target_reference.commit_id
1655 1653 pull_request.last_merge_status = merge_state.failure_reason
1656 1654 pull_request.last_merge_metadata = merge_state.metadata
1657 1655
1658 1656 pull_request.shadow_merge_ref = merge_state.merge_ref
1659 1657 Session().add(pull_request)
1660 1658 Session().commit()
1661 1659
1662 1660 return merge_state
1663 1661
1664 1662 def _workspace_id(self, pull_request):
1665 1663 workspace_id = 'pr-%s' % pull_request.pull_request_id
1666 1664 return workspace_id
1667 1665
1668 1666 def generate_repo_data(self, repo, commit_id=None, branch=None,
1669 1667 bookmark=None, translator=None):
1670 1668 from rhodecode.model.repo import RepoModel
1671 1669
1672 1670 all_refs, selected_ref = \
1673 1671 self._get_repo_pullrequest_sources(
1674 1672 repo.scm_instance(), commit_id=commit_id,
1675 1673 branch=branch, bookmark=bookmark, translator=translator)
1676 1674
1677 1675 refs_select2 = []
1678 1676 for element in all_refs:
1679 1677 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1680 1678 refs_select2.append({'text': element[1], 'children': children})
1681 1679
1682 1680 return {
1683 1681 'user': {
1684 1682 'user_id': repo.user.user_id,
1685 1683 'username': repo.user.username,
1686 1684 'firstname': repo.user.first_name,
1687 1685 'lastname': repo.user.last_name,
1688 1686 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1689 1687 },
1690 1688 'name': repo.repo_name,
1691 1689 'link': RepoModel().get_url(repo),
1692 1690 'description': h.chop_at_smart(repo.description_safe, '\n'),
1693 1691 'refs': {
1694 1692 'all_refs': all_refs,
1695 1693 'selected_ref': selected_ref,
1696 1694 'select2_refs': refs_select2
1697 1695 }
1698 1696 }
1699 1697
1700 1698 def generate_pullrequest_title(self, source, source_ref, target):
1701 1699 return u'{source}#{at_ref} to {target}'.format(
1702 1700 source=source,
1703 1701 at_ref=source_ref,
1704 1702 target=target,
1705 1703 )
1706 1704
1707 1705 def _cleanup_merge_workspace(self, pull_request):
1708 1706 # Merging related cleanup
1709 1707 repo_id = pull_request.target_repo.repo_id
1710 1708 target_scm = pull_request.target_repo.scm_instance()
1711 1709 workspace_id = self._workspace_id(pull_request)
1712 1710
1713 1711 try:
1714 1712 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1715 1713 except NotImplementedError:
1716 1714 pass
1717 1715
1718 1716 def _get_repo_pullrequest_sources(
1719 1717 self, repo, commit_id=None, branch=None, bookmark=None,
1720 1718 translator=None):
1721 1719 """
1722 1720 Return a structure with repo's interesting commits, suitable for
1723 1721 the selectors in pullrequest controller
1724 1722
1725 1723 :param commit_id: a commit that must be in the list somehow
1726 1724 and selected by default
1727 1725 :param branch: a branch that must be in the list and selected
1728 1726 by default - even if closed
1729 1727 :param bookmark: a bookmark that must be in the list and selected
1730 1728 """
1731 1729 _ = translator or get_current_request().translate
1732 1730
1733 1731 commit_id = safe_str(commit_id) if commit_id else None
1734 1732 branch = safe_unicode(branch) if branch else None
1735 1733 bookmark = safe_unicode(bookmark) if bookmark else None
1736 1734
1737 1735 selected = None
1738 1736
1739 1737 # order matters: first source that has commit_id in it will be selected
1740 1738 sources = []
1741 1739 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1742 1740 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1743 1741
1744 1742 if commit_id:
1745 1743 ref_commit = (h.short_id(commit_id), commit_id)
1746 1744 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1747 1745
1748 1746 sources.append(
1749 1747 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1750 1748 )
1751 1749
1752 1750 groups = []
1753 1751
1754 1752 for group_key, ref_list, group_name, match in sources:
1755 1753 group_refs = []
1756 1754 for ref_name, ref_id in ref_list:
1757 1755 ref_key = u'{}:{}:{}'.format(group_key, ref_name, ref_id)
1758 1756 group_refs.append((ref_key, ref_name))
1759 1757
1760 1758 if not selected:
1761 1759 if set([commit_id, match]) & set([ref_id, ref_name]):
1762 1760 selected = ref_key
1763 1761
1764 1762 if group_refs:
1765 1763 groups.append((group_refs, group_name))
1766 1764
1767 1765 if not selected:
1768 1766 ref = commit_id or branch or bookmark
1769 1767 if ref:
1770 1768 raise CommitDoesNotExistError(
1771 1769 u'No commit refs could be found matching: {}'.format(ref))
1772 1770 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1773 1771 selected = u'branch:{}:{}'.format(
1774 1772 safe_unicode(repo.DEFAULT_BRANCH_NAME),
1775 1773 safe_unicode(repo.branches[repo.DEFAULT_BRANCH_NAME])
1776 1774 )
1777 1775 elif repo.commit_ids:
1778 1776 # make the user select in this case
1779 1777 selected = None
1780 1778 else:
1781 1779 raise EmptyRepositoryError()
1782 1780 return groups, selected
1783 1781
1784 1782 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1785 1783 hide_whitespace_changes, diff_context):
1786 1784
1787 1785 return self._get_diff_from_pr_or_version(
1788 1786 source_repo, source_ref_id, target_ref_id,
1789 1787 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1790 1788
1791 1789 def _get_diff_from_pr_or_version(
1792 1790 self, source_repo, source_ref_id, target_ref_id,
1793 1791 hide_whitespace_changes, diff_context):
1794 1792
1795 1793 target_commit = source_repo.get_commit(
1796 1794 commit_id=safe_str(target_ref_id))
1797 1795 source_commit = source_repo.get_commit(
1798 1796 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
1799 1797 if isinstance(source_repo, Repository):
1800 1798 vcs_repo = source_repo.scm_instance()
1801 1799 else:
1802 1800 vcs_repo = source_repo
1803 1801
1804 1802 # TODO: johbo: In the context of an update, we cannot reach
1805 1803 # the old commit anymore with our normal mechanisms. It needs
1806 1804 # some sort of special support in the vcs layer to avoid this
1807 1805 # workaround.
1808 1806 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1809 1807 vcs_repo.alias == 'git'):
1810 1808 source_commit.raw_id = safe_str(source_ref_id)
1811 1809
1812 1810 log.debug('calculating diff between '
1813 1811 'source_ref:%s and target_ref:%s for repo `%s`',
1814 1812 target_ref_id, source_ref_id,
1815 1813 safe_unicode(vcs_repo.path))
1816 1814
1817 1815 vcs_diff = vcs_repo.get_diff(
1818 1816 commit1=target_commit, commit2=source_commit,
1819 1817 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1820 1818 return vcs_diff
1821 1819
1822 1820 def _is_merge_enabled(self, pull_request):
1823 1821 return self._get_general_setting(
1824 1822 pull_request, 'rhodecode_pr_merge_enabled')
1825 1823
1826 1824 def _use_rebase_for_merging(self, pull_request):
1827 1825 repo_type = pull_request.target_repo.repo_type
1828 1826 if repo_type == 'hg':
1829 1827 return self._get_general_setting(
1830 1828 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1831 1829 elif repo_type == 'git':
1832 1830 return self._get_general_setting(
1833 1831 pull_request, 'rhodecode_git_use_rebase_for_merging')
1834 1832
1835 1833 return False
1836 1834
1837 1835 def _user_name_for_merging(self, pull_request, user):
1838 1836 env_user_name_attr = os.environ.get('RC_MERGE_USER_NAME_ATTR', '')
1839 1837 if env_user_name_attr and hasattr(user, env_user_name_attr):
1840 1838 user_name_attr = env_user_name_attr
1841 1839 else:
1842 1840 user_name_attr = 'short_contact'
1843 1841
1844 1842 user_name = getattr(user, user_name_attr)
1845 1843 return user_name
1846 1844
1847 1845 def _close_branch_before_merging(self, pull_request):
1848 1846 repo_type = pull_request.target_repo.repo_type
1849 1847 if repo_type == 'hg':
1850 1848 return self._get_general_setting(
1851 1849 pull_request, 'rhodecode_hg_close_branch_before_merging')
1852 1850 elif repo_type == 'git':
1853 1851 return self._get_general_setting(
1854 1852 pull_request, 'rhodecode_git_close_branch_before_merging')
1855 1853
1856 1854 return False
1857 1855
1858 1856 def _get_general_setting(self, pull_request, settings_key, default=False):
1859 1857 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1860 1858 settings = settings_model.get_general_settings()
1861 1859 return settings.get(settings_key, default)
1862 1860
1863 1861 def _log_audit_action(self, action, action_data, user, pull_request):
1864 1862 audit_logger.store(
1865 1863 action=action,
1866 1864 action_data=action_data,
1867 1865 user=user,
1868 1866 repo=pull_request.target_repo)
1869 1867
1870 1868 def get_reviewer_functions(self):
1871 1869 """
1872 1870 Fetches functions for validation and fetching default reviewers.
1873 1871 If available we use the EE package, else we fallback to CE
1874 1872 package functions
1875 1873 """
1876 1874 try:
1877 1875 from rc_reviewers.utils import get_default_reviewers_data
1878 1876 from rc_reviewers.utils import validate_default_reviewers
1879 1877 except ImportError:
1880 1878 from rhodecode.apps.repository.utils import get_default_reviewers_data
1881 1879 from rhodecode.apps.repository.utils import validate_default_reviewers
1882 1880
1883 1881 return get_default_reviewers_data, validate_default_reviewers
1884 1882
1885 1883
1886 1884 class MergeCheck(object):
1887 1885 """
1888 1886 Perform Merge Checks and returns a check object which stores information
1889 1887 about merge errors, and merge conditions
1890 1888 """
1891 1889 TODO_CHECK = 'todo'
1892 1890 PERM_CHECK = 'perm'
1893 1891 REVIEW_CHECK = 'review'
1894 1892 MERGE_CHECK = 'merge'
1895 1893 WIP_CHECK = 'wip'
1896 1894
1897 1895 def __init__(self):
1898 1896 self.review_status = None
1899 1897 self.merge_possible = None
1900 1898 self.merge_msg = ''
1901 1899 self.merge_response = None
1902 1900 self.failed = None
1903 1901 self.errors = []
1904 1902 self.error_details = OrderedDict()
1905 1903 self.source_commit = AttributeDict()
1906 1904 self.target_commit = AttributeDict()
1907 1905
1908 1906 def __repr__(self):
1909 1907 return '<MergeCheck(possible:{}, failed:{}, errors:{})>'.format(
1910 1908 self.merge_possible, self.failed, self.errors)
1911 1909
1912 1910 def push_error(self, error_type, message, error_key, details):
1913 1911 self.failed = True
1914 1912 self.errors.append([error_type, message])
1915 1913 self.error_details[error_key] = dict(
1916 1914 details=details,
1917 1915 error_type=error_type,
1918 1916 message=message
1919 1917 )
1920 1918
1921 1919 @classmethod
1922 1920 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1923 1921 force_shadow_repo_refresh=False):
1924 1922 _ = translator
1925 1923 merge_check = cls()
1926 1924
1927 1925 # title has WIP:
1928 1926 if pull_request.work_in_progress:
1929 1927 log.debug("MergeCheck: cannot merge, title has wip: marker.")
1930 1928
1931 1929 msg = _('WIP marker in title prevents from accidental merge.')
1932 1930 merge_check.push_error('error', msg, cls.WIP_CHECK, pull_request.title)
1933 1931 if fail_early:
1934 1932 return merge_check
1935 1933
1936 1934 # permissions to merge
1937 1935 user_allowed_to_merge = PullRequestModel().check_user_merge(pull_request, auth_user)
1938 1936 if not user_allowed_to_merge:
1939 1937 log.debug("MergeCheck: cannot merge, approval is pending.")
1940 1938
1941 1939 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1942 1940 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1943 1941 if fail_early:
1944 1942 return merge_check
1945 1943
1946 1944 # permission to merge into the target branch
1947 1945 target_commit_id = pull_request.target_ref_parts.commit_id
1948 1946 if pull_request.target_ref_parts.type == 'branch':
1949 1947 branch_name = pull_request.target_ref_parts.name
1950 1948 else:
1951 1949 # for mercurial we can always figure out the branch from the commit
1952 1950 # in case of bookmark
1953 1951 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1954 1952 branch_name = target_commit.branch
1955 1953
1956 1954 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1957 1955 pull_request.target_repo.repo_name, branch_name)
1958 1956 if branch_perm and branch_perm == 'branch.none':
1959 1957 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1960 1958 branch_name, rule)
1961 1959 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1962 1960 if fail_early:
1963 1961 return merge_check
1964 1962
1965 1963 # review status, must be always present
1966 1964 review_status = pull_request.calculated_review_status()
1967 1965 merge_check.review_status = review_status
1968 1966
1969 1967 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1970 1968 if not status_approved:
1971 1969 log.debug("MergeCheck: cannot merge, approval is pending.")
1972 1970
1973 1971 msg = _('Pull request reviewer approval is pending.')
1974 1972
1975 1973 merge_check.push_error('warning', msg, cls.REVIEW_CHECK, review_status)
1976 1974
1977 1975 if fail_early:
1978 1976 return merge_check
1979 1977
1980 1978 # left over TODOs
1981 1979 todos = CommentsModel().get_pull_request_unresolved_todos(pull_request)
1982 1980 if todos:
1983 1981 log.debug("MergeCheck: cannot merge, {} "
1984 1982 "unresolved TODOs left.".format(len(todos)))
1985 1983
1986 1984 if len(todos) == 1:
1987 1985 msg = _('Cannot merge, {} TODO still not resolved.').format(
1988 1986 len(todos))
1989 1987 else:
1990 1988 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1991 1989 len(todos))
1992 1990
1993 1991 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1994 1992
1995 1993 if fail_early:
1996 1994 return merge_check
1997 1995
1998 1996 # merge possible, here is the filesystem simulation + shadow repo
1999 1997 merge_response, merge_status, msg = PullRequestModel().merge_status(
2000 1998 pull_request, translator=translator,
2001 1999 force_shadow_repo_refresh=force_shadow_repo_refresh)
2002 2000
2003 2001 merge_check.merge_possible = merge_status
2004 2002 merge_check.merge_msg = msg
2005 2003 merge_check.merge_response = merge_response
2006 2004
2007 2005 source_ref_id = pull_request.source_ref_parts.commit_id
2008 2006 target_ref_id = pull_request.target_ref_parts.commit_id
2009 2007
2010 2008 try:
2011 2009 source_commit, target_commit = PullRequestModel().get_flow_commits(pull_request)
2012 2010 merge_check.source_commit.changed = source_ref_id != source_commit.raw_id
2013 2011 merge_check.source_commit.ref_spec = pull_request.source_ref_parts
2014 2012 merge_check.source_commit.current_raw_id = source_commit.raw_id
2015 2013 merge_check.source_commit.previous_raw_id = source_ref_id
2016 2014
2017 2015 merge_check.target_commit.changed = target_ref_id != target_commit.raw_id
2018 2016 merge_check.target_commit.ref_spec = pull_request.target_ref_parts
2019 2017 merge_check.target_commit.current_raw_id = target_commit.raw_id
2020 2018 merge_check.target_commit.previous_raw_id = target_ref_id
2021 2019 except (SourceRefMissing, TargetRefMissing):
2022 2020 pass
2023 2021
2024 2022 if not merge_status:
2025 2023 log.debug("MergeCheck: cannot merge, pull request merge not possible.")
2026 2024 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
2027 2025
2028 2026 if fail_early:
2029 2027 return merge_check
2030 2028
2031 2029 log.debug('MergeCheck: is failed: %s', merge_check.failed)
2032 2030 return merge_check
2033 2031
2034 2032 @classmethod
2035 2033 def get_merge_conditions(cls, pull_request, translator):
2036 2034 _ = translator
2037 2035 merge_details = {}
2038 2036
2039 2037 model = PullRequestModel()
2040 2038 use_rebase = model._use_rebase_for_merging(pull_request)
2041 2039
2042 2040 if use_rebase:
2043 2041 merge_details['merge_strategy'] = dict(
2044 2042 details={},
2045 2043 message=_('Merge strategy: rebase')
2046 2044 )
2047 2045 else:
2048 2046 merge_details['merge_strategy'] = dict(
2049 2047 details={},
2050 2048 message=_('Merge strategy: explicit merge commit')
2051 2049 )
2052 2050
2053 2051 close_branch = model._close_branch_before_merging(pull_request)
2054 2052 if close_branch:
2055 2053 repo_type = pull_request.target_repo.repo_type
2056 2054 close_msg = ''
2057 2055 if repo_type == 'hg':
2058 2056 close_msg = _('Source branch will be closed before the merge.')
2059 2057 elif repo_type == 'git':
2060 2058 close_msg = _('Source branch will be deleted after the merge.')
2061 2059
2062 2060 merge_details['close_branch'] = dict(
2063 2061 details={},
2064 2062 message=close_msg
2065 2063 )
2066 2064
2067 2065 return merge_details
2068 2066
2069 2067
2070 2068 ChangeTuple = collections.namedtuple(
2071 2069 'ChangeTuple', ['added', 'common', 'removed', 'total'])
2072 2070
2073 2071 FileChangeTuple = collections.namedtuple(
2074 2072 'FileChangeTuple', ['added', 'modified', 'removed'])
@@ -1,1050 +1,1046 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 users model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import datetime
28 28 import ipaddress
29 29
30 30 from pyramid.threadlocal import get_current_request
31 31 from sqlalchemy.exc import DatabaseError
32 32
33 33 from rhodecode import events
34 34 from rhodecode.lib.user_log_filter import user_log_filter
35 35 from rhodecode.lib.utils2 import (
36 36 safe_unicode, get_current_rhodecode_user, action_logger_generic,
37 37 AttributeDict, str2bool)
38 38 from rhodecode.lib.exceptions import (
39 39 DefaultUserException, UserOwnsReposException, UserOwnsRepoGroupsException,
40 40 UserOwnsUserGroupsException, NotAllowedToCreateUserError,
41 41 UserOwnsPullRequestsException, UserOwnsArtifactsException)
42 42 from rhodecode.lib.caching_query import FromCache
43 43 from rhodecode.model import BaseModel
44 44 from rhodecode.model.db import (
45 45 _hash_key, true, false, or_, joinedload, User, UserToPerm,
46 46 UserEmailMap, UserIpMap, UserLog)
47 47 from rhodecode.model.meta import Session
48 48 from rhodecode.model.auth_token import AuthTokenModel
49 49 from rhodecode.model.repo_group import RepoGroupModel
50 50
51 51 log = logging.getLogger(__name__)
52 52
53 53
54 54 class UserModel(BaseModel):
55 55 cls = User
56 56
57 57 def get(self, user_id, cache=False):
58 58 user = self.sa.query(User)
59 59 if cache:
60 60 user = user.options(
61 61 FromCache("sql_cache_short", "get_user_%s" % user_id))
62 62 return user.get(user_id)
63 63
64 64 def get_user(self, user):
65 65 return self._get_user(user)
66 66
67 67 def _serialize_user(self, user):
68 68 import rhodecode.lib.helpers as h
69 69
70 70 return {
71 71 'id': user.user_id,
72 72 'first_name': user.first_name,
73 73 'last_name': user.last_name,
74 74 'username': user.username,
75 75 'email': user.email,
76 76 'icon_link': h.gravatar_url(user.email, 30),
77 77 'profile_link': h.link_to_user(user),
78 78 'value_display': h.escape(h.person(user)),
79 79 'value': user.username,
80 80 'value_type': 'user',
81 81 'active': user.active,
82 82 }
83 83
84 84 def get_users(self, name_contains=None, limit=20, only_active=True):
85 85
86 86 query = self.sa.query(User)
87 87 if only_active:
88 88 query = query.filter(User.active == true())
89 89
90 90 if name_contains:
91 91 ilike_expression = u'%{}%'.format(safe_unicode(name_contains))
92 92 query = query.filter(
93 93 or_(
94 94 User.name.ilike(ilike_expression),
95 95 User.lastname.ilike(ilike_expression),
96 96 User.username.ilike(ilike_expression)
97 97 )
98 98 )
99 99 query = query.limit(limit)
100 100 users = query.all()
101 101
102 102 _users = [
103 103 self._serialize_user(user) for user in users
104 104 ]
105 105 return _users
106 106
107 107 def get_by_username(self, username, cache=False, case_insensitive=False):
108 108
109 109 if case_insensitive:
110 110 user = self.sa.query(User).filter(User.username.ilike(username))
111 111 else:
112 112 user = self.sa.query(User)\
113 113 .filter(User.username == username)
114 114 if cache:
115 115 name_key = _hash_key(username)
116 116 user = user.options(
117 117 FromCache("sql_cache_short", "get_user_%s" % name_key))
118 118 return user.scalar()
119 119
120 120 def get_by_email(self, email, cache=False, case_insensitive=False):
121 121 return User.get_by_email(email, case_insensitive, cache)
122 122
123 123 def get_by_auth_token(self, auth_token, cache=False):
124 124 return User.get_by_auth_token(auth_token, cache)
125 125
126 126 def get_active_user_count(self, cache=False):
127 127 qry = User.query().filter(
128 128 User.active == true()).filter(
129 129 User.username != User.DEFAULT_USER)
130 130 if cache:
131 131 qry = qry.options(
132 132 FromCache("sql_cache_short", "get_active_users"))
133 133 return qry.count()
134 134
135 135 def create(self, form_data, cur_user=None):
136 136 if not cur_user:
137 137 cur_user = getattr(get_current_rhodecode_user(), 'username', None)
138 138
139 139 user_data = {
140 140 'username': form_data['username'],
141 141 'password': form_data['password'],
142 142 'email': form_data['email'],
143 143 'firstname': form_data['firstname'],
144 144 'lastname': form_data['lastname'],
145 145 'active': form_data['active'],
146 146 'extern_type': form_data['extern_type'],
147 147 'extern_name': form_data['extern_name'],
148 148 'admin': False,
149 149 'cur_user': cur_user
150 150 }
151 151
152 152 if 'create_repo_group' in form_data:
153 153 user_data['create_repo_group'] = str2bool(
154 154 form_data.get('create_repo_group'))
155 155
156 156 try:
157 157 if form_data.get('password_change'):
158 158 user_data['force_password_change'] = True
159 159 return UserModel().create_or_update(**user_data)
160 160 except Exception:
161 161 log.error(traceback.format_exc())
162 162 raise
163 163
164 164 def update_user(self, user, skip_attrs=None, **kwargs):
165 165 from rhodecode.lib.auth import get_crypt_password
166 166
167 167 user = self._get_user(user)
168 168 if user.username == User.DEFAULT_USER:
169 169 raise DefaultUserException(
170 170 "You can't edit this user (`%(username)s`) since it's "
171 171 "crucial for entire application" % {
172 172 'username': user.username})
173 173
174 174 # first store only defaults
175 175 user_attrs = {
176 176 'updating_user_id': user.user_id,
177 177 'username': user.username,
178 178 'password': user.password,
179 179 'email': user.email,
180 180 'firstname': user.name,
181 181 'lastname': user.lastname,
182 182 'description': user.description,
183 183 'active': user.active,
184 184 'admin': user.admin,
185 185 'extern_name': user.extern_name,
186 186 'extern_type': user.extern_type,
187 187 'language': user.user_data.get('language')
188 188 }
189 189
190 190 # in case there's new_password, that comes from form, use it to
191 191 # store password
192 192 if kwargs.get('new_password'):
193 193 kwargs['password'] = kwargs['new_password']
194 194
195 195 # cleanups, my_account password change form
196 196 kwargs.pop('current_password', None)
197 197 kwargs.pop('new_password', None)
198 198
199 199 # cleanups, user edit password change form
200 200 kwargs.pop('password_confirmation', None)
201 201 kwargs.pop('password_change', None)
202 202
203 203 # create repo group on user creation
204 204 kwargs.pop('create_repo_group', None)
205 205
206 206 # legacy forms send name, which is the firstname
207 207 firstname = kwargs.pop('name', None)
208 208 if firstname:
209 209 kwargs['firstname'] = firstname
210 210
211 211 for k, v in kwargs.items():
212 212 # skip if we don't want to update this
213 213 if skip_attrs and k in skip_attrs:
214 214 continue
215 215
216 216 user_attrs[k] = v
217 217
218 218 try:
219 219 return self.create_or_update(**user_attrs)
220 220 except Exception:
221 221 log.error(traceback.format_exc())
222 222 raise
223 223
224 224 def create_or_update(
225 225 self, username, password, email, firstname='', lastname='',
226 226 active=True, admin=False, extern_type=None, extern_name=None,
227 227 cur_user=None, plugin=None, force_password_change=False,
228 228 allow_to_create_user=True, create_repo_group=None,
229 229 updating_user_id=None, language=None, description='',
230 230 strict_creation_check=True):
231 231 """
232 232 Creates a new instance if not found, or updates current one
233 233
234 234 :param username:
235 235 :param password:
236 236 :param email:
237 237 :param firstname:
238 238 :param lastname:
239 239 :param active:
240 240 :param admin:
241 241 :param extern_type:
242 242 :param extern_name:
243 243 :param cur_user:
244 244 :param plugin: optional plugin this method was called from
245 245 :param force_password_change: toggles new or existing user flag
246 246 for password change
247 247 :param allow_to_create_user: Defines if the method can actually create
248 248 new users
249 249 :param create_repo_group: Defines if the method should also
250 250 create an repo group with user name, and owner
251 251 :param updating_user_id: if we set it up this is the user we want to
252 252 update this allows to editing username.
253 253 :param language: language of user from interface.
254 254 :param description: user description
255 255 :param strict_creation_check: checks for allowed creation license wise etc.
256 256
257 257 :returns: new User object with injected `is_new_user` attribute.
258 258 """
259 259
260 260 if not cur_user:
261 261 cur_user = getattr(get_current_rhodecode_user(), 'username', None)
262 262
263 263 from rhodecode.lib.auth import (
264 264 get_crypt_password, check_password)
265 265 from rhodecode.lib import hooks_base
266 266
267 267 def _password_change(new_user, password):
268 268 old_password = new_user.password or ''
269 269 # empty password
270 270 if not old_password:
271 271 return False
272 272
273 273 # password check is only needed for RhodeCode internal auth calls
274 274 # in case it's a plugin we don't care
275 275 if not plugin:
276 276
277 277 # first check if we gave crypted password back, and if it
278 278 # matches it's not password change
279 279 if new_user.password == password:
280 280 return False
281 281
282 282 password_match = check_password(password, old_password)
283 283 if not password_match:
284 284 return True
285 285
286 286 return False
287 287
288 288 # read settings on default personal repo group creation
289 289 if create_repo_group is None:
290 290 default_create_repo_group = RepoGroupModel()\
291 291 .get_default_create_personal_repo_group()
292 292 create_repo_group = default_create_repo_group
293 293
294 294 user_data = {
295 295 'username': username,
296 296 'password': password,
297 297 'email': email,
298 298 'firstname': firstname,
299 299 'lastname': lastname,
300 300 'active': active,
301 301 'admin': admin
302 302 }
303 303
304 304 if updating_user_id:
305 305 log.debug('Checking for existing account in RhodeCode '
306 306 'database with user_id `%s` ', updating_user_id)
307 307 user = User.get(updating_user_id)
308 308 else:
309 309 log.debug('Checking for existing account in RhodeCode '
310 310 'database with username `%s` ', username)
311 311 user = User.get_by_username(username, case_insensitive=True)
312 312
313 313 if user is None:
314 314 # we check internal flag if this method is actually allowed to
315 315 # create new user
316 316 if not allow_to_create_user:
317 317 msg = ('Method wants to create new user, but it is not '
318 318 'allowed to do so')
319 319 log.warning(msg)
320 320 raise NotAllowedToCreateUserError(msg)
321 321
322 322 log.debug('Creating new user %s', username)
323 323
324 324 # only if we create user that is active
325 325 new_active_user = active
326 326 if new_active_user and strict_creation_check:
327 327 # raises UserCreationError if it's not allowed for any reason to
328 328 # create new active user, this also executes pre-create hooks
329 329 hooks_base.check_allowed_create_user(user_data, cur_user, strict_check=True)
330 330 events.trigger(events.UserPreCreate(user_data))
331 331 new_user = User()
332 332 edit = False
333 333 else:
334 334 log.debug('updating user `%s`', username)
335 335 events.trigger(events.UserPreUpdate(user, user_data))
336 336 new_user = user
337 337 edit = True
338 338
339 339 # we're not allowed to edit default user
340 340 if user.username == User.DEFAULT_USER:
341 341 raise DefaultUserException(
342 342 "You can't edit this user (`%(username)s`) since it's "
343 343 "crucial for entire application"
344 344 % {'username': user.username})
345 345
346 346 # inject special attribute that will tell us if User is new or old
347 347 new_user.is_new_user = not edit
348 348 # for users that didn's specify auth type, we use RhodeCode built in
349 349 from rhodecode.authentication.plugins import auth_rhodecode
350 350 extern_name = extern_name or auth_rhodecode.RhodeCodeAuthPlugin.uid
351 351 extern_type = extern_type or auth_rhodecode.RhodeCodeAuthPlugin.uid
352 352
353 353 try:
354 354 new_user.username = username
355 355 new_user.admin = admin
356 356 new_user.email = email
357 357 new_user.active = active
358 358 new_user.extern_name = safe_unicode(extern_name)
359 359 new_user.extern_type = safe_unicode(extern_type)
360 360 new_user.name = firstname
361 361 new_user.lastname = lastname
362 362 new_user.description = description
363 363
364 364 # set password only if creating an user or password is changed
365 365 if not edit or _password_change(new_user, password):
366 366 reason = 'new password' if edit else 'new user'
367 367 log.debug('Updating password reason=>%s', reason)
368 368 new_user.password = get_crypt_password(password) if password else None
369 369
370 370 if force_password_change:
371 371 new_user.update_userdata(force_password_change=True)
372 372 if language:
373 373 new_user.update_userdata(language=language)
374 374 new_user.update_userdata(notification_status=True)
375 375
376 376 self.sa.add(new_user)
377 377
378 378 if not edit and create_repo_group:
379 379 RepoGroupModel().create_personal_repo_group(
380 380 new_user, commit_early=False)
381 381
382 382 if not edit:
383 383 # add the RSS token
384 384 self.add_auth_token(
385 385 user=username, lifetime_minutes=-1,
386 386 role=self.auth_token_role.ROLE_FEED,
387 387 description=u'Generated feed token')
388 388
389 389 kwargs = new_user.get_dict()
390 390 # backward compat, require api_keys present
391 391 kwargs['api_keys'] = kwargs['auth_tokens']
392 392 hooks_base.create_user(created_by=cur_user, **kwargs)
393 393 events.trigger(events.UserPostCreate(user_data))
394 394 return new_user
395 395 except (DatabaseError,):
396 396 log.error(traceback.format_exc())
397 397 raise
398 398
399 399 def create_registration(self, form_data,
400 400 extern_name='rhodecode', extern_type='rhodecode'):
401 401 from rhodecode.model.notification import NotificationModel
402 402 from rhodecode.model.notification import EmailNotificationModel
403 403
404 404 try:
405 405 form_data['admin'] = False
406 406 form_data['extern_name'] = extern_name
407 407 form_data['extern_type'] = extern_type
408 408 new_user = self.create(form_data)
409 409
410 410 self.sa.add(new_user)
411 411 self.sa.flush()
412 412
413 413 user_data = new_user.get_dict()
414 414 user_data.update({
415 415 'first_name': user_data.get('firstname'),
416 416 'last_name': user_data.get('lastname'),
417 417 })
418 418 kwargs = {
419 419 # use SQLALCHEMY safe dump of user data
420 420 'user': AttributeDict(user_data),
421 421 'date': datetime.datetime.now()
422 422 }
423 423 notification_type = EmailNotificationModel.TYPE_REGISTRATION
424 424 # pre-generate the subject for notification itself
425 (subject,
426 _h, _e, # we don't care about those
427 body_plaintext) = EmailNotificationModel().render_email(
425 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
428 426 notification_type, **kwargs)
429 427
430 428 # create notification objects, and emails
431 429 NotificationModel().create(
432 430 created_by=new_user,
433 431 notification_subject=subject,
434 432 notification_body=body_plaintext,
435 433 notification_type=notification_type,
436 434 recipients=None, # all admins
437 435 email_kwargs=kwargs,
438 436 )
439 437
440 438 return new_user
441 439 except Exception:
442 440 log.error(traceback.format_exc())
443 441 raise
444 442
445 443 def _handle_user_repos(self, username, repositories, handle_user,
446 444 handle_mode=None):
447 445
448 446 left_overs = True
449 447
450 448 from rhodecode.model.repo import RepoModel
451 449
452 450 if handle_mode == 'detach':
453 451 for obj in repositories:
454 452 obj.user = handle_user
455 453 # set description we know why we super admin now owns
456 454 # additional repositories that were orphaned !
457 455 obj.description += ' \n::detached repository from deleted user: %s' % (username,)
458 456 self.sa.add(obj)
459 457 left_overs = False
460 458 elif handle_mode == 'delete':
461 459 for obj in repositories:
462 460 RepoModel().delete(obj, forks='detach')
463 461 left_overs = False
464 462
465 463 # if nothing is done we have left overs left
466 464 return left_overs
467 465
468 466 def _handle_user_repo_groups(self, username, repository_groups, handle_user,
469 467 handle_mode=None):
470 468
471 469 left_overs = True
472 470
473 471 from rhodecode.model.repo_group import RepoGroupModel
474 472
475 473 if handle_mode == 'detach':
476 474 for r in repository_groups:
477 475 r.user = handle_user
478 476 # set description we know why we super admin now owns
479 477 # additional repositories that were orphaned !
480 478 r.group_description += ' \n::detached repository group from deleted user: %s' % (username,)
481 479 r.personal = False
482 480 self.sa.add(r)
483 481 left_overs = False
484 482 elif handle_mode == 'delete':
485 483 for r in repository_groups:
486 484 RepoGroupModel().delete(r)
487 485 left_overs = False
488 486
489 487 # if nothing is done we have left overs left
490 488 return left_overs
491 489
492 490 def _handle_user_user_groups(self, username, user_groups, handle_user,
493 491 handle_mode=None):
494 492
495 493 left_overs = True
496 494
497 495 from rhodecode.model.user_group import UserGroupModel
498 496
499 497 if handle_mode == 'detach':
500 498 for r in user_groups:
501 499 for user_user_group_to_perm in r.user_user_group_to_perm:
502 500 if user_user_group_to_perm.user.username == username:
503 501 user_user_group_to_perm.user = handle_user
504 502 r.user = handle_user
505 503 # set description we know why we super admin now owns
506 504 # additional repositories that were orphaned !
507 505 r.user_group_description += ' \n::detached user group from deleted user: %s' % (username,)
508 506 self.sa.add(r)
509 507 left_overs = False
510 508 elif handle_mode == 'delete':
511 509 for r in user_groups:
512 510 UserGroupModel().delete(r)
513 511 left_overs = False
514 512
515 513 # if nothing is done we have left overs left
516 514 return left_overs
517 515
518 516 def _handle_user_pull_requests(self, username, pull_requests, handle_user,
519 517 handle_mode=None):
520 518 left_overs = True
521 519
522 520 from rhodecode.model.pull_request import PullRequestModel
523 521
524 522 if handle_mode == 'detach':
525 523 for pr in pull_requests:
526 524 pr.user_id = handle_user.user_id
527 525 # set description we know why we super admin now owns
528 526 # additional repositories that were orphaned !
529 527 pr.description += ' \n::detached pull requests from deleted user: %s' % (username,)
530 528 self.sa.add(pr)
531 529 left_overs = False
532 530 elif handle_mode == 'delete':
533 531 for pr in pull_requests:
534 532 PullRequestModel().delete(pr)
535 533
536 534 left_overs = False
537 535
538 536 # if nothing is done we have left overs left
539 537 return left_overs
540 538
541 539 def _handle_user_artifacts(self, username, artifacts, handle_user,
542 540 handle_mode=None):
543 541
544 542 left_overs = True
545 543
546 544 if handle_mode == 'detach':
547 545 for a in artifacts:
548 546 a.upload_user = handle_user
549 547 # set description we know why we super admin now owns
550 548 # additional artifacts that were orphaned !
551 549 a.file_description += ' \n::detached artifact from deleted user: %s' % (username,)
552 550 self.sa.add(a)
553 551 left_overs = False
554 552 elif handle_mode == 'delete':
555 553 from rhodecode.apps.file_store import utils as store_utils
556 554 request = get_current_request()
557 555 storage = store_utils.get_file_storage(request.registry.settings)
558 556 for a in artifacts:
559 557 file_uid = a.file_uid
560 558 storage.delete(file_uid)
561 559 self.sa.delete(a)
562 560
563 561 left_overs = False
564 562
565 563 # if nothing is done we have left overs left
566 564 return left_overs
567 565
568 566 def delete(self, user, cur_user=None, handle_repos=None,
569 567 handle_repo_groups=None, handle_user_groups=None,
570 568 handle_pull_requests=None, handle_artifacts=None, handle_new_owner=None):
571 569 from rhodecode.lib import hooks_base
572 570
573 571 if not cur_user:
574 572 cur_user = getattr(get_current_rhodecode_user(), 'username', None)
575 573
576 574 user = self._get_user(user)
577 575
578 576 try:
579 577 if user.username == User.DEFAULT_USER:
580 578 raise DefaultUserException(
581 579 u"You can't remove this user since it's"
582 580 u" crucial for entire application")
583 581 handle_user = handle_new_owner or self.cls.get_first_super_admin()
584 582 log.debug('New detached objects owner %s', handle_user)
585 583
586 584 left_overs = self._handle_user_repos(
587 585 user.username, user.repositories, handle_user, handle_repos)
588 586 if left_overs and user.repositories:
589 587 repos = [x.repo_name for x in user.repositories]
590 588 raise UserOwnsReposException(
591 589 u'user "%(username)s" still owns %(len_repos)s repositories and cannot be '
592 590 u'removed. Switch owners or remove those repositories:%(list_repos)s'
593 591 % {'username': user.username, 'len_repos': len(repos),
594 592 'list_repos': ', '.join(repos)})
595 593
596 594 left_overs = self._handle_user_repo_groups(
597 595 user.username, user.repository_groups, handle_user, handle_repo_groups)
598 596 if left_overs and user.repository_groups:
599 597 repo_groups = [x.group_name for x in user.repository_groups]
600 598 raise UserOwnsRepoGroupsException(
601 599 u'user "%(username)s" still owns %(len_repo_groups)s repository groups and cannot be '
602 600 u'removed. Switch owners or remove those repository groups:%(list_repo_groups)s'
603 601 % {'username': user.username, 'len_repo_groups': len(repo_groups),
604 602 'list_repo_groups': ', '.join(repo_groups)})
605 603
606 604 left_overs = self._handle_user_user_groups(
607 605 user.username, user.user_groups, handle_user, handle_user_groups)
608 606 if left_overs and user.user_groups:
609 607 user_groups = [x.users_group_name for x in user.user_groups]
610 608 raise UserOwnsUserGroupsException(
611 609 u'user "%s" still owns %s user groups and cannot be '
612 610 u'removed. Switch owners or remove those user groups:%s'
613 611 % (user.username, len(user_groups), ', '.join(user_groups)))
614 612
615 613 left_overs = self._handle_user_pull_requests(
616 614 user.username, user.user_pull_requests, handle_user, handle_pull_requests)
617 615 if left_overs and user.user_pull_requests:
618 616 pull_requests = ['!{}'.format(x.pull_request_id) for x in user.user_pull_requests]
619 617 raise UserOwnsPullRequestsException(
620 618 u'user "%s" still owns %s pull requests and cannot be '
621 619 u'removed. Switch owners or remove those pull requests:%s'
622 620 % (user.username, len(pull_requests), ', '.join(pull_requests)))
623 621
624 622 left_overs = self._handle_user_artifacts(
625 623 user.username, user.artifacts, handle_user, handle_artifacts)
626 624 if left_overs and user.artifacts:
627 625 artifacts = [x.file_uid for x in user.artifacts]
628 626 raise UserOwnsArtifactsException(
629 627 u'user "%s" still owns %s artifacts and cannot be '
630 628 u'removed. Switch owners or remove those artifacts:%s'
631 629 % (user.username, len(artifacts), ', '.join(artifacts)))
632 630
633 631 user_data = user.get_dict() # fetch user data before expire
634 632
635 633 # we might change the user data with detach/delete, make sure
636 634 # the object is marked as expired before actually deleting !
637 635 self.sa.expire(user)
638 636 self.sa.delete(user)
639 637
640 638 hooks_base.delete_user(deleted_by=cur_user, **user_data)
641 639 except Exception:
642 640 log.error(traceback.format_exc())
643 641 raise
644 642
645 643 def reset_password_link(self, data, pwd_reset_url):
646 644 from rhodecode.lib.celerylib import tasks, run_task
647 645 from rhodecode.model.notification import EmailNotificationModel
648 646 user_email = data['email']
649 647 try:
650 648 user = User.get_by_email(user_email)
651 649 if user:
652 650 log.debug('password reset user found %s', user)
653 651
654 652 email_kwargs = {
655 653 'password_reset_url': pwd_reset_url,
656 654 'user': user,
657 655 'email': user_email,
658 656 'date': datetime.datetime.now(),
659 657 'first_admin_email': User.get_first_super_admin().email
660 658 }
661 659
662 (subject, headers, email_body,
663 email_body_plaintext) = EmailNotificationModel().render_email(
660 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
664 661 EmailNotificationModel.TYPE_PASSWORD_RESET, **email_kwargs)
665 662
666 663 recipients = [user_email]
667 664
668 665 action_logger_generic(
669 666 'sending password reset email to user: {}'.format(
670 667 user), namespace='security.password_reset')
671 668
672 669 run_task(tasks.send_email, recipients, subject,
673 670 email_body_plaintext, email_body)
674 671
675 672 else:
676 673 log.debug("password reset email %s not found", user_email)
677 674 except Exception:
678 675 log.error(traceback.format_exc())
679 676 return False
680 677
681 678 return True
682 679
683 680 def reset_password(self, data):
684 681 from rhodecode.lib.celerylib import tasks, run_task
685 682 from rhodecode.model.notification import EmailNotificationModel
686 683 from rhodecode.lib import auth
687 684 user_email = data['email']
688 685 pre_db = True
689 686 try:
690 687 user = User.get_by_email(user_email)
691 688 new_passwd = auth.PasswordGenerator().gen_password(
692 689 12, auth.PasswordGenerator.ALPHABETS_BIG_SMALL)
693 690 if user:
694 691 user.password = auth.get_crypt_password(new_passwd)
695 692 # also force this user to reset his password !
696 693 user.update_userdata(force_password_change=True)
697 694
698 695 Session().add(user)
699 696
700 697 # now delete the token in question
701 698 UserApiKeys = AuthTokenModel.cls
702 699 UserApiKeys().query().filter(
703 700 UserApiKeys.api_key == data['token']).delete()
704 701
705 702 Session().commit()
706 703 log.info('successfully reset password for `%s`', user_email)
707 704
708 705 if new_passwd is None:
709 706 raise Exception('unable to generate new password')
710 707
711 708 pre_db = False
712 709
713 710 email_kwargs = {
714 711 'new_password': new_passwd,
715 712 'user': user,
716 713 'email': user_email,
717 714 'date': datetime.datetime.now(),
718 715 'first_admin_email': User.get_first_super_admin().email
719 716 }
720 717
721 (subject, headers, email_body,
722 email_body_plaintext) = EmailNotificationModel().render_email(
718 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
723 719 EmailNotificationModel.TYPE_PASSWORD_RESET_CONFIRMATION,
724 720 **email_kwargs)
725 721
726 722 recipients = [user_email]
727 723
728 724 action_logger_generic(
729 725 'sent new password to user: {} with email: {}'.format(
730 726 user, user_email), namespace='security.password_reset')
731 727
732 728 run_task(tasks.send_email, recipients, subject,
733 729 email_body_plaintext, email_body)
734 730
735 731 except Exception:
736 732 log.error('Failed to update user password')
737 733 log.error(traceback.format_exc())
738 734 if pre_db:
739 735 # we rollback only if local db stuff fails. If it goes into
740 736 # run_task, we're pass rollback state this wouldn't work then
741 737 Session().rollback()
742 738
743 739 return True
744 740
745 741 def fill_data(self, auth_user, user_id=None, api_key=None, username=None):
746 742 """
747 743 Fetches auth_user by user_id,or api_key if present.
748 744 Fills auth_user attributes with those taken from database.
749 745 Additionally set's is_authenitated if lookup fails
750 746 present in database
751 747
752 748 :param auth_user: instance of user to set attributes
753 749 :param user_id: user id to fetch by
754 750 :param api_key: api key to fetch by
755 751 :param username: username to fetch by
756 752 """
757 753 def token_obfuscate(token):
758 754 if token:
759 755 return token[:4] + "****"
760 756
761 757 if user_id is None and api_key is None and username is None:
762 758 raise Exception('You need to pass user_id, api_key or username')
763 759
764 760 log.debug(
765 761 'AuthUser: fill data execution based on: '
766 762 'user_id:%s api_key:%s username:%s', user_id, api_key, username)
767 763 try:
768 764 dbuser = None
769 765 if user_id:
770 766 dbuser = self.get(user_id)
771 767 elif api_key:
772 768 dbuser = self.get_by_auth_token(api_key)
773 769 elif username:
774 770 dbuser = self.get_by_username(username)
775 771
776 772 if not dbuser:
777 773 log.warning(
778 774 'Unable to lookup user by id:%s api_key:%s username:%s',
779 775 user_id, token_obfuscate(api_key), username)
780 776 return False
781 777 if not dbuser.active:
782 778 log.debug('User `%s:%s` is inactive, skipping fill data',
783 779 username, user_id)
784 780 return False
785 781
786 782 log.debug('AuthUser: filling found user:%s data', dbuser)
787 783
788 784 attrs = {
789 785 'user_id': dbuser.user_id,
790 786 'username': dbuser.username,
791 787 'name': dbuser.name,
792 788 'first_name': dbuser.first_name,
793 789 'firstname': dbuser.firstname,
794 790 'last_name': dbuser.last_name,
795 791 'lastname': dbuser.lastname,
796 792 'admin': dbuser.admin,
797 793 'active': dbuser.active,
798 794
799 795 'email': dbuser.email,
800 796 'emails': dbuser.emails_cached(),
801 797 'short_contact': dbuser.short_contact,
802 798 'full_contact': dbuser.full_contact,
803 799 'full_name': dbuser.full_name,
804 800 'full_name_or_username': dbuser.full_name_or_username,
805 801
806 802 '_api_key': dbuser._api_key,
807 803 '_user_data': dbuser._user_data,
808 804
809 805 'created_on': dbuser.created_on,
810 806 'extern_name': dbuser.extern_name,
811 807 'extern_type': dbuser.extern_type,
812 808
813 809 'inherit_default_permissions': dbuser.inherit_default_permissions,
814 810
815 811 'language': dbuser.language,
816 812 'last_activity': dbuser.last_activity,
817 813 'last_login': dbuser.last_login,
818 814 'password': dbuser.password,
819 815 }
820 816 auth_user.__dict__.update(attrs)
821 817 except Exception:
822 818 log.error(traceback.format_exc())
823 819 auth_user.is_authenticated = False
824 820 return False
825 821
826 822 return True
827 823
828 824 def has_perm(self, user, perm):
829 825 perm = self._get_perm(perm)
830 826 user = self._get_user(user)
831 827
832 828 return UserToPerm.query().filter(UserToPerm.user == user)\
833 829 .filter(UserToPerm.permission == perm).scalar() is not None
834 830
835 831 def grant_perm(self, user, perm):
836 832 """
837 833 Grant user global permissions
838 834
839 835 :param user:
840 836 :param perm:
841 837 """
842 838 user = self._get_user(user)
843 839 perm = self._get_perm(perm)
844 840 # if this permission is already granted skip it
845 841 _perm = UserToPerm.query()\
846 842 .filter(UserToPerm.user == user)\
847 843 .filter(UserToPerm.permission == perm)\
848 844 .scalar()
849 845 if _perm:
850 846 return
851 847 new = UserToPerm()
852 848 new.user = user
853 849 new.permission = perm
854 850 self.sa.add(new)
855 851 return new
856 852
857 853 def revoke_perm(self, user, perm):
858 854 """
859 855 Revoke users global permissions
860 856
861 857 :param user:
862 858 :param perm:
863 859 """
864 860 user = self._get_user(user)
865 861 perm = self._get_perm(perm)
866 862
867 863 obj = UserToPerm.query()\
868 864 .filter(UserToPerm.user == user)\
869 865 .filter(UserToPerm.permission == perm)\
870 866 .scalar()
871 867 if obj:
872 868 self.sa.delete(obj)
873 869
874 870 def add_extra_email(self, user, email):
875 871 """
876 872 Adds email address to UserEmailMap
877 873
878 874 :param user:
879 875 :param email:
880 876 """
881 877
882 878 user = self._get_user(user)
883 879
884 880 obj = UserEmailMap()
885 881 obj.user = user
886 882 obj.email = email
887 883 self.sa.add(obj)
888 884 return obj
889 885
890 886 def delete_extra_email(self, user, email_id):
891 887 """
892 888 Removes email address from UserEmailMap
893 889
894 890 :param user:
895 891 :param email_id:
896 892 """
897 893 user = self._get_user(user)
898 894 obj = UserEmailMap.query().get(email_id)
899 895 if obj and obj.user_id == user.user_id:
900 896 self.sa.delete(obj)
901 897
902 898 def parse_ip_range(self, ip_range):
903 899 ip_list = []
904 900
905 901 def make_unique(value):
906 902 seen = []
907 903 return [c for c in value if not (c in seen or seen.append(c))]
908 904
909 905 # firsts split by commas
910 906 for ip_range in ip_range.split(','):
911 907 if not ip_range:
912 908 continue
913 909 ip_range = ip_range.strip()
914 910 if '-' in ip_range:
915 911 start_ip, end_ip = ip_range.split('-', 1)
916 912 start_ip = ipaddress.ip_address(safe_unicode(start_ip.strip()))
917 913 end_ip = ipaddress.ip_address(safe_unicode(end_ip.strip()))
918 914 parsed_ip_range = []
919 915
920 916 for index in range(int(start_ip), int(end_ip) + 1):
921 917 new_ip = ipaddress.ip_address(index)
922 918 parsed_ip_range.append(str(new_ip))
923 919 ip_list.extend(parsed_ip_range)
924 920 else:
925 921 ip_list.append(ip_range)
926 922
927 923 return make_unique(ip_list)
928 924
929 925 def add_extra_ip(self, user, ip, description=None):
930 926 """
931 927 Adds ip address to UserIpMap
932 928
933 929 :param user:
934 930 :param ip:
935 931 """
936 932
937 933 user = self._get_user(user)
938 934 obj = UserIpMap()
939 935 obj.user = user
940 936 obj.ip_addr = ip
941 937 obj.description = description
942 938 self.sa.add(obj)
943 939 return obj
944 940
945 941 auth_token_role = AuthTokenModel.cls
946 942
947 943 def add_auth_token(self, user, lifetime_minutes, role, description=u'',
948 944 scope_callback=None):
949 945 """
950 946 Add AuthToken for user.
951 947
952 948 :param user: username/user_id
953 949 :param lifetime_minutes: in minutes the lifetime for token, -1 equals no limit
954 950 :param role: one of AuthTokenModel.cls.ROLE_*
955 951 :param description: optional string description
956 952 """
957 953
958 954 token = AuthTokenModel().create(
959 955 user, description, lifetime_minutes, role)
960 956 if scope_callback and callable(scope_callback):
961 957 # call the callback if we provide, used to attach scope for EE edition
962 958 scope_callback(token)
963 959 return token
964 960
965 961 def delete_extra_ip(self, user, ip_id):
966 962 """
967 963 Removes ip address from UserIpMap
968 964
969 965 :param user:
970 966 :param ip_id:
971 967 """
972 968 user = self._get_user(user)
973 969 obj = UserIpMap.query().get(ip_id)
974 970 if obj and obj.user_id == user.user_id:
975 971 self.sa.delete(obj)
976 972
977 973 def get_accounts_in_creation_order(self, current_user=None):
978 974 """
979 975 Get accounts in order of creation for deactivation for license limits
980 976
981 977 pick currently logged in user, and append to the list in position 0
982 978 pick all super-admins in order of creation date and add it to the list
983 979 pick all other accounts in order of creation and add it to the list.
984 980
985 981 Based on that list, the last accounts can be disabled as they are
986 982 created at the end and don't include any of the super admins as well
987 983 as the current user.
988 984
989 985 :param current_user: optionally current user running this operation
990 986 """
991 987
992 988 if not current_user:
993 989 current_user = get_current_rhodecode_user()
994 990 active_super_admins = [
995 991 x.user_id for x in User.query()
996 992 .filter(User.user_id != current_user.user_id)
997 993 .filter(User.active == true())
998 994 .filter(User.admin == true())
999 995 .order_by(User.created_on.asc())]
1000 996
1001 997 active_regular_users = [
1002 998 x.user_id for x in User.query()
1003 999 .filter(User.user_id != current_user.user_id)
1004 1000 .filter(User.active == true())
1005 1001 .filter(User.admin == false())
1006 1002 .order_by(User.created_on.asc())]
1007 1003
1008 1004 list_of_accounts = [current_user.user_id]
1009 1005 list_of_accounts += active_super_admins
1010 1006 list_of_accounts += active_regular_users
1011 1007
1012 1008 return list_of_accounts
1013 1009
1014 1010 def deactivate_last_users(self, expected_users, current_user=None):
1015 1011 """
1016 1012 Deactivate accounts that are over the license limits.
1017 1013 Algorithm of which accounts to disabled is based on the formula:
1018 1014
1019 1015 Get current user, then super admins in creation order, then regular
1020 1016 active users in creation order.
1021 1017
1022 1018 Using that list we mark all accounts from the end of it as inactive.
1023 1019 This way we block only latest created accounts.
1024 1020
1025 1021 :param expected_users: list of users in special order, we deactivate
1026 1022 the end N amount of users from that list
1027 1023 """
1028 1024
1029 1025 list_of_accounts = self.get_accounts_in_creation_order(
1030 1026 current_user=current_user)
1031 1027
1032 1028 for acc_id in list_of_accounts[expected_users + 1:]:
1033 1029 user = User.get(acc_id)
1034 1030 log.info('Deactivating account %s for license unlock', user)
1035 1031 user.active = False
1036 1032 Session().add(user)
1037 1033 Session().commit()
1038 1034
1039 1035 return
1040 1036
1041 1037 def get_user_log(self, user, filter_term):
1042 1038 user_log = UserLog.query()\
1043 1039 .filter(or_(UserLog.user_id == user.user_id,
1044 1040 UserLog.username == user.username))\
1045 1041 .options(joinedload(UserLog.user))\
1046 1042 .options(joinedload(UserLog.repository))\
1047 1043 .order_by(UserLog.action_date.desc())
1048 1044
1049 1045 user_log = user_log_filter(user_log, filter_term)
1050 1046 return user_log
@@ -1,29 +1,24 b''
1 1 <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
2 2
3 3 <html>
4 4 <head></head>
5 5
6 6 <body>
7 7
8 8 SUBJECT:
9 9 <pre>${c.subject}</pre>
10 10
11 HEADERS:
12 <pre>
13 ${c.headers}
14 </pre>
15
16 11 PLAINTEXT:
17 12 <pre>
18 13 ${c.email_body_plaintext|n}
19 14 </pre>
20 15
21 16 </body>
22 17 </html>
23 18 <br/><br/>
24 19
25 20 HTML:
26 21
27 22 ${c.email_body|n}
28 23
29 24
@@ -1,642 +1,639 b''
1 1 ## -*- coding: utf-8 -*-
2 2
3 3 ## helpers
4 4 <%def name="tag_button(text, tag_type=None)">
5 5 <%
6 6 color_scheme = {
7 7 'default': 'border:1px solid #979797;color:#666666;background-color:#f9f9f9',
8 8 'approved': 'border:1px solid #0ac878;color:#0ac878;background-color:#f9f9f9',
9 9 'rejected': 'border:1px solid #e85e4d;color:#e85e4d;background-color:#f9f9f9',
10 10 'under_review': 'border:1px solid #ffc854;color:#ffc854;background-color:#f9f9f9',
11 11 }
12 12
13 13 css_style = ';'.join([
14 14 'display:inline',
15 15 'border-radius:2px',
16 16 'font-size:12px',
17 17 'padding:.2em',
18 18 ])
19 19
20 20 %>
21 21 <pre style="${css_style}; ${color_scheme.get(tag_type, color_scheme['default'])}">${text}</pre>
22 22 </%def>
23 23
24 24 <%def name="status_text(text, tag_type=None)">
25 25 <%
26 26 color_scheme = {
27 27 'default': 'color:#666666',
28 28 'approved': 'color:#0ac878',
29 29 'rejected': 'color:#e85e4d',
30 30 'under_review': 'color:#ffc854',
31 31 }
32 32 %>
33 33 <span style="font-weight:bold;font-size:12px;padding:.2em;${color_scheme.get(tag_type, color_scheme['default'])}">${text}</span>
34 34 </%def>
35 35
36 36 <%def name="gravatar_img(email, size=16)">
37 37 <%
38 38 css_style = ';'.join([
39 39 'padding: 0',
40 40 'margin: -4px 0',
41 41 'border-radius: 50%',
42 42 'box-sizing: content-box',
43 43 'display: inline',
44 44 'line-height: 1em',
45 45 'min-width: 16px',
46 46 'min-height: 16px',
47 47 ])
48 48 %>
49 49
50 50 <img alt="gravatar" style="${css_style}" src="${h.gravatar_url(email, size)}" height="${size}" width="${size}">
51 51 </%def>
52 52
53 53 <%def name="link_css()">\
54 54 <%
55 55 css_style = ';'.join([
56 56 'color:#427cc9',
57 57 'text-decoration:none',
58 58 'cursor:pointer'
59 59 ])
60 60 %>\
61 61 ${css_style}\
62 62 </%def>
63 63
64 64 ## Constants
65 65 <%
66 66 text_regular = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, sans-serif"
67 67 text_monospace = "'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace"
68 68
69 69 %>
70 70
71 ## headers we additionally can set for email
72 <%def name="headers()" filter="n,trim"></%def>
73
74 71 <%def name="plaintext_footer()" filter="trim">
75 72 ${_('This is a notification from RhodeCode.')} ${instance_url}
76 73 </%def>
77 74
78 75 <%def name="body_plaintext()" filter="n,trim">
79 76 ## this example is not called itself but overridden in each template
80 77 ## the plaintext_footer should be at the bottom of both html and text emails
81 78 ${self.plaintext_footer()}
82 79 </%def>
83 80
84 81 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
85 82 <html xmlns="http://www.w3.org/1999/xhtml">
86 83 <head>
87 84 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
88 85 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
89 86 <title>${self.subject()}</title>
90 87 <style type="text/css">
91 88 /* Based on The MailChimp Reset INLINE: Yes. */
92 89 #outlook a {
93 90 padding: 0;
94 91 }
95 92
96 93 /* Force Outlook to provide a "view in browser" menu link. */
97 94 body {
98 95 width: 100% !important;
99 96 -webkit-text-size-adjust: 100%;
100 97 -ms-text-size-adjust: 100%;
101 98 margin: 0;
102 99 padding: 0;
103 100 font-family: ${text_regular|n};
104 101 color: #000000;
105 102 }
106 103
107 104 /* Prevent Webkit and Windows Mobile platforms from changing default font sizes.*/
108 105 .ExternalClass {
109 106 width: 100%;
110 107 }
111 108
112 109 /* Force Hotmail to display emails at full width */
113 110 .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {
114 111 line-height: 100%;
115 112 }
116 113
117 114 /* Forces Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */
118 115 #backgroundTable {
119 116 margin: 0;
120 117 padding: 0;
121 118 line-height: 100% !important;
122 119 }
123 120
124 121 /* End reset */
125 122
126 123 /* defaults for images*/
127 124 img {
128 125 outline: none;
129 126 text-decoration: none;
130 127 -ms-interpolation-mode: bicubic;
131 128 }
132 129
133 130 a img {
134 131 border: none;
135 132 }
136 133
137 134 .image_fix {
138 135 display: block;
139 136 }
140 137
141 138 body {
142 139 line-height: 1.2em;
143 140 }
144 141
145 142 p {
146 143 margin: 0 0 20px;
147 144 }
148 145
149 146 h1, h2, h3, h4, h5, h6 {
150 147 color: #323232 !important;
151 148 }
152 149
153 150 a {
154 151 color: #427cc9;
155 152 text-decoration: none;
156 153 outline: none;
157 154 cursor: pointer;
158 155 }
159 156
160 157 a:focus {
161 158 outline: none;
162 159 }
163 160
164 161 a:hover {
165 162 color: #305b91;
166 163 }
167 164
168 165 h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {
169 166 color: #427cc9 !important;
170 167 text-decoration: none !important;
171 168 }
172 169
173 170 h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {
174 171 color: #305b91 !important;
175 172 }
176 173
177 174 h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {
178 175 color: #305b91 !important;
179 176 }
180 177
181 178 table {
182 179 font-size: 13px;
183 180 border-collapse: collapse;
184 181 mso-table-lspace: 0pt;
185 182 mso-table-rspace: 0pt;
186 183 }
187 184
188 185 table tr {
189 186 display: table-row;
190 187 vertical-align: inherit;
191 188 border-color: inherit;
192 189 border-spacing: 0 3px;
193 190 }
194 191
195 192 table td {
196 193 padding: .65em 1em .65em 0;
197 194 border-collapse: collapse;
198 195 vertical-align: top;
199 196 text-align: left;
200 197 }
201 198
202 199 input {
203 200 display: inline;
204 201 border-radius: 2px;
205 202 border: 1px solid #dbd9da;
206 203 padding: .5em;
207 204 }
208 205
209 206 input:focus {
210 207 outline: 1px solid #979797
211 208 }
212 209
213 210 code {
214 211 font-family: ${text_monospace|n};
215 212 white-space: pre-line !important;
216 213 color: #000000;
217 214 }
218 215
219 216 ul.changes-ul {
220 217 list-style: none;
221 218 list-style-type: none;
222 219 padding: 0;
223 220 margin: 10px 0;
224 221 }
225 222 ul.changes-ul li {
226 223 list-style: none;
227 224 list-style-type: none;
228 225 margin: 2px 0;
229 226 }
230 227
231 228 @media only screen and (-webkit-min-device-pixel-ratio: 2) {
232 229 /* Put your iPhone 4g styles in here */
233 230 }
234 231
235 232 /* Android targeting */
236 233 @media only screen and (-webkit-device-pixel-ratio:.75){
237 234 /* Put CSS for low density (ldpi) Android layouts in here */
238 235 }
239 236 @media only screen and (-webkit-device-pixel-ratio:1){
240 237 /* Put CSS for medium density (mdpi) Android layouts in here */
241 238 }
242 239 @media only screen and (-webkit-device-pixel-ratio:1.5){
243 240 /* Put CSS for high density (hdpi) Android layouts in here */
244 241 }
245 242 /* end Android targeting */
246 243
247 244 /** MARKDOWN styling **/
248 245 div.markdown-block {
249 246 clear: both;
250 247 overflow: hidden;
251 248 margin: 0;
252 249 padding: 3px 5px 3px
253 250 }
254 251
255 252 div.markdown-block h1,
256 253 div.markdown-block h2,
257 254 div.markdown-block h3,
258 255 div.markdown-block h4,
259 256 div.markdown-block h5,
260 257 div.markdown-block h6 {
261 258 border-bottom: none !important;
262 259 padding: 0 !important;
263 260 overflow: visible !important
264 261 }
265 262
266 263 div.markdown-block h1,
267 264 div.markdown-block h2 {
268 265 border-bottom: 1px #e6e5e5 solid !important
269 266 }
270 267
271 268 div.markdown-block h1 {
272 269 font-size: 32px;
273 270 margin: 15px 0 15px 0 !important;
274 271 padding-bottom: 5px !important
275 272 }
276 273
277 274 div.markdown-block h2 {
278 275 font-size: 24px !important;
279 276 margin: 34px 0 10px 0 !important;
280 277 padding-top: 15px !important;
281 278 padding-bottom: 8px !important
282 279 }
283 280
284 281 div.markdown-block h3 {
285 282 font-size: 18px !important;
286 283 margin: 30px 0 8px 0 !important;
287 284 padding-bottom: 2px !important
288 285 }
289 286
290 287 div.markdown-block h4 {
291 288 font-size: 13px !important;
292 289 margin: 18px 0 3px 0 !important
293 290 }
294 291
295 292 div.markdown-block h5 {
296 293 font-size: 12px !important;
297 294 margin: 15px 0 3px 0 !important
298 295 }
299 296
300 297 div.markdown-block h6 {
301 298 font-size: 12px;
302 299 color: #777777;
303 300 margin: 15px 0 3px 0 !important
304 301 }
305 302
306 303 div.markdown-block hr {
307 304 border: 0;
308 305 color: #e6e5e5;
309 306 background-color: #e6e5e5;
310 307 height: 3px;
311 308 margin-bottom: 13px
312 309 }
313 310
314 311 div.markdown-block ol,
315 312 div.markdown-block ul,
316 313 div.markdown-block p,
317 314 div.markdown-block blockquote,
318 315 div.markdown-block dl,
319 316 div.markdown-block li,
320 317 div.markdown-block table {
321 318 margin: 3px 0 13px 0 !important;
322 319 color: #424242 !important;
323 320 font-size: 13px !important;
324 321 font-family: ${text_regular|n};
325 322 font-weight: normal !important;
326 323 overflow: visible !important;
327 324 line-height: 140% !important
328 325 }
329 326
330 327 div.markdown-block pre {
331 328 margin: 3px 0 13px 0 !important;
332 329 padding: .5em;
333 330 color: #424242 !important;
334 331 font-size: 13px !important;
335 332 overflow: visible !important;
336 333 line-height: 140% !important;
337 334 background-color: #F5F5F5
338 335 }
339 336
340 337 div.markdown-block img {
341 338 border-style: none;
342 339 background-color: #fff;
343 340 padding-right: 20px;
344 341 max-width: 100%
345 342 }
346 343
347 344 div.markdown-block strong {
348 345 font-weight: 600;
349 346 margin: 0
350 347 }
351 348
352 349 div.markdown-block ul.checkbox, div.markdown-block ol.checkbox {
353 350 padding-left: 20px !important;
354 351 margin-top: 0 !important;
355 352 margin-bottom: 18px !important
356 353 }
357 354
358 355 div.markdown-block ul, div.markdown-block ol {
359 356 padding-left: 30px !important;
360 357 margin-top: 0 !important;
361 358 margin-bottom: 18px !important
362 359 }
363 360
364 361 div.markdown-block ul.checkbox li, div.markdown-block ol.checkbox li {
365 362 list-style: none !important;
366 363 margin: 6px !important;
367 364 padding: 0 !important
368 365 }
369 366
370 367 div.markdown-block ul li, div.markdown-block ol li {
371 368 list-style: disc !important;
372 369 margin: 6px !important;
373 370 padding: 0 !important
374 371 }
375 372
376 373 div.markdown-block ol li {
377 374 list-style: decimal !important
378 375 }
379 376
380 377 div.markdown-block #message {
381 378 -webkit-border-radius: 2px;
382 379 -moz-border-radius: 2px;
383 380 border-radius: 2px;
384 381 border: 1px solid #dbd9da;
385 382 display: block;
386 383 width: 100%;
387 384 height: 60px;
388 385 margin: 6px 0
389 386 }
390 387
391 388 div.markdown-block button, div.markdown-block #ws {
392 389 font-size: 13px;
393 390 padding: 4px 6px;
394 391 -webkit-border-radius: 2px;
395 392 -moz-border-radius: 2px;
396 393 border-radius: 2px;
397 394 border: 1px solid #dbd9da;
398 395 background-color: #eeeeee
399 396 }
400 397
401 398 div.markdown-block code,
402 399 div.markdown-block pre,
403 400 div.markdown-block #ws,
404 401 div.markdown-block #message {
405 402 font-family: ${text_monospace|n};
406 403 font-size: 11px;
407 404 -webkit-border-radius: 2px;
408 405 -moz-border-radius: 2px;
409 406 border-radius: 2px;
410 407 background-color: #FFFFFF;
411 408 color: #7E7F7F
412 409 }
413 410
414 411 div.markdown-block code {
415 412 border: 1px solid #7E7F7F;
416 413 margin: 0 2px;
417 414 padding: 0 5px
418 415 }
419 416
420 417 div.markdown-block pre {
421 418 border: 1px solid #7E7F7F;
422 419 overflow: auto;
423 420 padding: .5em;
424 421 background-color: #FFFFFF;
425 422 }
426 423
427 424 div.markdown-block pre > code {
428 425 border: 0;
429 426 margin: 0;
430 427 padding: 0
431 428 }
432 429
433 430 div.rst-block {
434 431 clear: both;
435 432 overflow: hidden;
436 433 margin: 0;
437 434 padding: 3px 5px 3px
438 435 }
439 436
440 437 div.rst-block h2 {
441 438 font-weight: normal
442 439 }
443 440
444 441 div.rst-block h1,
445 442 div.rst-block h2,
446 443 div.rst-block h3,
447 444 div.rst-block h4,
448 445 div.rst-block h5,
449 446 div.rst-block h6 {
450 447 border-bottom: 0 !important;
451 448 margin: 0 !important;
452 449 padding: 0 !important;
453 450 line-height: 1.5em !important
454 451 }
455 452
456 453 div.rst-block h1:first-child {
457 454 padding-top: .25em !important
458 455 }
459 456
460 457 div.rst-block h2, div.rst-block h3 {
461 458 margin: 1em 0 !important
462 459 }
463 460
464 461 div.rst-block h1, div.rst-block h2 {
465 462 border-bottom: 1px #e6e5e5 solid !important
466 463 }
467 464
468 465 div.rst-block h2 {
469 466 margin-top: 1.5em !important;
470 467 padding-top: .5em !important
471 468 }
472 469
473 470 div.rst-block p {
474 471 color: black !important;
475 472 margin: 1em 0 !important;
476 473 line-height: 1.5em !important
477 474 }
478 475
479 476 div.rst-block ul {
480 477 list-style: disc !important;
481 478 margin: 1em 0 1em 2em !important;
482 479 clear: both
483 480 }
484 481
485 482 div.rst-block ol {
486 483 list-style: decimal;
487 484 margin: 1em 0 1em 2em !important
488 485 }
489 486
490 487 div.rst-block pre, div.rst-block code {
491 488 font: 12px "Bitstream Vera Sans Mono", "Courier", monospace
492 489 }
493 490
494 491 div.rst-block code {
495 492 font-size: 12px !important;
496 493 background-color: ghostWhite !important;
497 494 color: #444 !important;
498 495 padding: 0 .2em !important;
499 496 border: 1px solid #7E7F7F !important
500 497 }
501 498
502 499 div.rst-block pre code {
503 500 padding: 0 !important;
504 501 font-size: 12px !important;
505 502 background-color: #eee !important;
506 503 border: none !important
507 504 }
508 505
509 506 div.rst-block pre {
510 507 margin: 1em 0;
511 508 padding: 15px;
512 509 border: 1px solid #7E7F7F;
513 510 -webkit-border-radius: 2px;
514 511 -moz-border-radius: 2px;
515 512 border-radius: 2px;
516 513 overflow: auto;
517 514 font-size: 12px;
518 515 color: #444;
519 516 background-color: #FFFFFF;
520 517 }
521 518
522 519 .clear-both {
523 520 clear:both;
524 521 }
525 522
526 523 /*elasticmatch is custom rhodecode tag*/
527 524 .codehilite .c-ElasticMatch {
528 525 background-color: #faffa6;
529 526 padding: 0.2em;
530 527 }
531 528
532 529 .codehilite .c-ElasticMatch { background-color: #faffa6; padding: 0.2em;}
533 530 .codehilite .hll { background-color: #ffffcc }
534 531 .codehilite .c { color: #408080; font-style: italic } /* Comment */
535 532 .codehilite .err { border: none } /* Error */
536 533 .codehilite .k { color: #008000; font-weight: bold } /* Keyword */
537 534 .codehilite .o { color: #666666 } /* Operator */
538 535 .codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */
539 536 .codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */
540 537 .codehilite .cp { color: #BC7A00 } /* Comment.Preproc */
541 538 .codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */
542 539 .codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */
543 540 .codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */
544 541 .codehilite .gd { color: #A00000 } /* Generic.Deleted */
545 542 .codehilite .ge { font-style: italic } /* Generic.Emph */
546 543 .codehilite .gr { color: #FF0000 } /* Generic.Error */
547 544 .codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */
548 545 .codehilite .gi { color: #00A000 } /* Generic.Inserted */
549 546 .codehilite .go { color: #888888 } /* Generic.Output */
550 547 .codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
551 548 .codehilite .gs { font-weight: bold } /* Generic.Strong */
552 549 .codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
553 550 .codehilite .gt { color: #0044DD } /* Generic.Traceback */
554 551 .codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
555 552 .codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
556 553 .codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
557 554 .codehilite .kp { color: #008000 } /* Keyword.Pseudo */
558 555 .codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
559 556 .codehilite .kt { color: #B00040 } /* Keyword.Type */
560 557 .codehilite .m { color: #666666 } /* Literal.Number */
561 558 .codehilite .s { color: #BA2121 } /* Literal.String */
562 559 .codehilite .na { color: #7D9029 } /* Name.Attribute */
563 560 .codehilite .nb { color: #008000 } /* Name.Builtin */
564 561 .codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */
565 562 .codehilite .no { color: #880000 } /* Name.Constant */
566 563 .codehilite .nd { color: #AA22FF } /* Name.Decorator */
567 564 .codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */
568 565 .codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
569 566 .codehilite .nf { color: #0000FF } /* Name.Function */
570 567 .codehilite .nl { color: #A0A000 } /* Name.Label */
571 568 .codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
572 569 .codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */
573 570 .codehilite .nv { color: #19177C } /* Name.Variable */
574 571 .codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
575 572 .codehilite .w { color: #bbbbbb } /* Text.Whitespace */
576 573 .codehilite .mb { color: #666666 } /* Literal.Number.Bin */
577 574 .codehilite .mf { color: #666666 } /* Literal.Number.Float */
578 575 .codehilite .mh { color: #666666 } /* Literal.Number.Hex */
579 576 .codehilite .mi { color: #666666 } /* Literal.Number.Integer */
580 577 .codehilite .mo { color: #666666 } /* Literal.Number.Oct */
581 578 .codehilite .sa { color: #BA2121 } /* Literal.String.Affix */
582 579 .codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */
583 580 .codehilite .sc { color: #BA2121 } /* Literal.String.Char */
584 581 .codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */
585 582 .codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
586 583 .codehilite .s2 { color: #BA2121 } /* Literal.String.Double */
587 584 .codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
588 585 .codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */
589 586 .codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
590 587 .codehilite .sx { color: #008000 } /* Literal.String.Other */
591 588 .codehilite .sr { color: #BB6688 } /* Literal.String.Regex */
592 589 .codehilite .s1 { color: #BA2121 } /* Literal.String.Single */
593 590 .codehilite .ss { color: #19177C } /* Literal.String.Symbol */
594 591 .codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */
595 592 .codehilite .fm { color: #0000FF } /* Name.Function.Magic */
596 593 .codehilite .vc { color: #19177C } /* Name.Variable.Class */
597 594 .codehilite .vg { color: #19177C } /* Name.Variable.Global */
598 595 .codehilite .vi { color: #19177C } /* Name.Variable.Instance */
599 596 .codehilite .vm { color: #19177C } /* Name.Variable.Magic */
600 597 .codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */
601 598
602 599 </style>
603 600
604 601 </head>
605 602 <body>
606 603
607 604 <div>
608 605 <!-- Wrapper/Container Table: Use a wrapper table to control the width and the background color consistently of your email. Use this approach instead of setting attributes on the body tag. -->
609 606 <table cellpadding="0" cellspacing="0" border="0" id="backgroundTable" align="left" style="margin:1%;width:97%;padding:0;font-family:${text_regular|n};font-weight:100;border:1px solid #dbd9da">
610 607 <tr>
611 608 <td valign="top" style="padding:0;">
612 609 <table cellpadding="0" cellspacing="0" border="0" align="left" width="100%">
613 610 <tr>
614 611 <td style="width:100%;padding:10px 15px;background-color:#202020" valign="top">
615 612 <a style="color:#eeeeee;text-decoration:none;" href="${instance_url}">
616 613 ${_('RhodeCode')}
617 614 % if rhodecode_instance_name:
618 615 - ${rhodecode_instance_name}
619 616 % endif
620 617 </a>
621 618 </td>
622 619 </tr>
623 620 <tr style="background-color: #fff">
624 621 <td style="padding:15px;" valign="top">${self.body()}</td>
625 622 </tr>
626 623 </table>
627 624 </td>
628 625 </tr>
629 626 </table>
630 627 <!-- End of wrapper table -->
631 628 </div>
632 629
633 630 <div style="width:100%; clear: both; height: 1px">&nbsp;</div>
634 631
635 632 <div style="margin-left:1%;font-weight:100;font-size:11px;color:#666666;text-decoration:none;font-family:${text_monospace};">
636 633 ${_('This is a notification from RhodeCode.')}
637 634 <a style="font-weight:100;font-size:11px;color:#666666;text-decoration:none;font-family:${text_monospace};" href="${instance_url}">
638 635 ${instance_url}
639 636 </a>
640 637 </div>
641 638 </body>
642 639 </html>
@@ -1,172 +1,173 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3 <%namespace name="base" file="base.mako"/>
4 4
5 5 ## EMAIL SUBJECT
6 6 <%def name="subject()" filter="n,trim,whitespace_filter">
7 7 <%
8 8 data = {
9 9 'user': '@'+h.person(user),
10 10 'repo_name': repo_name,
11 11 'status': status_change,
12 12 'comment_file': comment_file,
13 13 'comment_line': comment_line,
14 14 'comment_type': comment_type,
15 15 'comment_id': comment_id,
16 16
17 17 'commit_id': h.show_id(commit),
18 'mention_prefix': '[mention] ' if mention else '',
18 19 }
19 20 %>
20 21
21 22
22 23 % if comment_file:
23 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on file `{comment_file}` in commit `{commit_id}`').format(**data)} ${_('in the `{repo_name}` repository').format(**data) |n}
24 ${_('{mention_prefix}{user} left a {comment_type} on file `{comment_file}` in commit `{commit_id}`').format(**data)} ${_('in the `{repo_name}` repository').format(**data) |n}
24 25 % else:
25 26 % if status_change:
26 ${(_('[mention]') if mention else '')} ${_('[status: {status}] {user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the `{repo_name}` repository').format(**data) |n}
27 ${_('{mention_prefix}[status: {status}] {user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the `{repo_name}` repository').format(**data) |n}
27 28 % else:
28 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the `{repo_name}` repository').format(**data) |n}
29 ${_('{mention_prefix}{user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the `{repo_name}` repository').format(**data) |n}
29 30 % endif
30 31 % endif
31 32
32 33 </%def>
33 34
34 35 ## PLAINTEXT VERSION OF BODY
35 36 <%def name="body_plaintext()" filter="n,trim">
36 37 <%
37 38 data = {
38 39 'user': h.person(user),
39 40 'repo_name': repo_name,
40 41 'status': status_change,
41 42 'comment_file': comment_file,
42 43 'comment_line': comment_line,
43 44 'comment_type': comment_type,
44 45 'comment_id': comment_id,
45 46
46 47 'commit_id': h.show_id(commit),
47 48 }
48 49 %>
49 50
50 51 * ${_('Comment link')}: ${commit_comment_url}
51 52
52 53 %if status_change:
53 54 * ${_('Commit status')}: ${_('Status was changed to')}: *${status_change}*
54 55
55 56 %endif
56 57 * ${_('Commit')}: ${h.show_id(commit)}
57 58
58 59 * ${_('Commit message')}: ${commit.message}
59 60
60 61 %if comment_file:
61 62 * ${_('File: {comment_file} on line {comment_line}').format(**data)}
62 63
63 64 %endif
64 65 % if comment_type == 'todo':
65 66 ${('Inline' if comment_file else 'General')} ${_('`TODO` number')} ${comment_id}:
66 67 % else:
67 68 ${('Inline' if comment_file else 'General')} ${_('`Note` number')} ${comment_id}:
68 69 % endif
69 70
70 71 ${comment_body |n, trim}
71 72
72 73 ---
73 74 ${self.plaintext_footer()}
74 75 </%def>
75 76
76 77
77 78 <%
78 79 data = {
79 80 'user': h.person(user),
80 81 'comment_file': comment_file,
81 82 'comment_line': comment_line,
82 83 'comment_type': comment_type,
83 84 'comment_id': comment_id,
84 85 'renderer_type': renderer_type or 'plain',
85 86
86 87 'repo': commit_target_repo_url,
87 88 'repo_name': repo_name,
88 89 'commit_id': h.show_id(commit),
89 90 }
90 91 %>
91 92
92 93 ## header
93 94 <table style="text-align:left;vertical-align:middle;width: 100%">
94 95 <tr>
95 96 <td style="width:100%;border-bottom:1px solid #dbd9da;">
96 97
97 98 <div style="margin: 0; font-weight: bold">
98 99 <div class="clear-both" style="margin-bottom: 4px">
99 100 <span style="color:#7E7F7F">@${h.person(user.username)}</span>
100 101 ${_('left a')}
101 102 <a href="${commit_comment_url}" style="${base.link_css()}">
102 103 % if comment_file:
103 104 ${_('{comment_type} on file `{comment_file}` in commit.').format(**data)}
104 105 % else:
105 106 ${_('{comment_type} on commit.').format(**data) |n}
106 107 % endif
107 108 </a>
108 109 </div>
109 110 <div style="margin-top: 10px"></div>
110 111 ${_('Commit')} <code>${data['commit_id']}</code> ${_('of repository')}: ${data['repo_name']}
111 112 </div>
112 113
113 114 </td>
114 115 </tr>
115 116
116 117 </table>
117 118 <div class="clear-both"></div>
118 119 ## main body
119 120 <table style="text-align:left;vertical-align:middle;width: 100%">
120 121
121 122 ## spacing def
122 123 <tr>
123 124 <td style="width: 130px"></td>
124 125 <td></td>
125 126 </tr>
126 127
127 128 % if status_change:
128 129 <tr>
129 130 <td style="padding-right:20px;">${_('Commit Status')}:</td>
130 131 <td>
131 132 ${_('Status was changed to')}: ${base.status_text(status_change, tag_type=status_change_type)}
132 133 </td>
133 134 </tr>
134 135 % endif
135 136
136 137 <tr>
137 138 <td style="padding-right:20px;">${_('Commit')}:</td>
138 139 <td>
139 140 <a href="${commit_comment_url}" style="${base.link_css()}">${h.show_id(commit)}</a>
140 141 </td>
141 142 </tr>
142 143 <tr>
143 144 <td style="padding-right:20px;">${_('Commit message')}:</td>
144 145 <td style="white-space:pre-wrap">${h.urlify_commit_message(commit.message, repo_name)}</td>
145 146 </tr>
146 147
147 148 % if comment_file:
148 149 <tr>
149 150 <td style="padding-right:20px;">${_('File')}:</td>
150 151 <td><a href="${commit_comment_url}" style="${base.link_css()}">${_('`{comment_file}` on line {comment_line}').format(**data)}</a></td>
151 152 </tr>
152 153 % endif
153 154
154 155 <tr style="border-bottom:1px solid #dbd9da;">
155 156 <td colspan="2" style="padding-right:20px;">
156 157 % if comment_type == 'todo':
157 158 ${('Inline' if comment_file else 'General')} ${_('`TODO` number')} ${comment_id}:
158 159 % else:
159 160 ${('Inline' if comment_file else 'General')} ${_('`Note` number')} ${comment_id}:
160 161 % endif
161 162 </td>
162 163 </tr>
163 164
164 165 <tr>
165 166 <td colspan="2" style="background: #F7F7F7">${h.render(comment_body, renderer=data['renderer_type'], mentions=True)}</td>
166 167 </tr>
167 168
168 169 <tr>
169 170 <td><a href="${commit_comment_reply_url}">${_('Reply')}</a></td>
170 171 <td></td>
171 172 </tr>
172 173 </table>
@@ -1,203 +1,204 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3 <%namespace name="base" file="base.mako"/>
4 4
5 5 ## EMAIL SUBJECT
6 6 <%def name="subject()" filter="n,trim,whitespace_filter">
7 7 <%
8 8 data = {
9 9 'user': '@'+h.person(user),
10 10 'repo_name': repo_name,
11 11 'status': status_change,
12 12 'comment_file': comment_file,
13 13 'comment_line': comment_line,
14 14 'comment_type': comment_type,
15 15 'comment_id': comment_id,
16 16
17 17 'pr_title': pull_request.title,
18 18 'pr_id': pull_request.pull_request_id,
19 'mention_prefix': '[mention] ' if mention else '',
19 20 }
20 21 %>
21 22
22 23
23 24 % if comment_file:
24 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on file `{comment_file}` in pull request !{pr_id}: "{pr_title}"').format(**data) |n}
25 ${_('{mention_prefix}{user} left a {comment_type} on file `{comment_file}` in pull request !{pr_id}: "{pr_title}"').format(**data) |n}
25 26 % else:
26 27 % if status_change:
27 ${(_('[mention]') if mention else '')} ${_('[status: {status}] {user} left a {comment_type} on pull request !{pr_id}: "{pr_title}"').format(**data) |n}
28 ${_('{mention_prefix}[status: {status}] {user} left a {comment_type} on pull request !{pr_id}: "{pr_title}"').format(**data) |n}
28 29 % else:
29 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on pull request !{pr_id}: "{pr_title}"').format(**data) |n}
30 ${_('{mention_prefix}{user} left a {comment_type} on pull request !{pr_id}: "{pr_title}"').format(**data) |n}
30 31 % endif
31 32 % endif
32 33
33 34 </%def>
34 35
35 36 ## PLAINTEXT VERSION OF BODY
36 37 <%def name="body_plaintext()" filter="n,trim">
37 38 <%
38 39 data = {
39 40 'user': h.person(user),
40 41 'repo_name': repo_name,
41 42 'status': status_change,
42 43 'comment_file': comment_file,
43 44 'comment_line': comment_line,
44 45 'comment_type': comment_type,
45 46 'comment_id': comment_id,
46 47
47 48 'pr_title': pull_request.title,
48 49 'pr_id': pull_request.pull_request_id,
49 50 'source_ref_type': pull_request.source_ref_parts.type,
50 51 'source_ref_name': pull_request.source_ref_parts.name,
51 52 'target_ref_type': pull_request.target_ref_parts.type,
52 53 'target_ref_name': pull_request.target_ref_parts.name,
53 54 'source_repo': pull_request_source_repo.repo_name,
54 55 'target_repo': pull_request_target_repo.repo_name,
55 56 'source_repo_url': pull_request_source_repo_url,
56 57 'target_repo_url': pull_request_target_repo_url,
57 58 }
58 59 %>
59 60
60 61 * ${_('Comment link')}: ${pr_comment_url}
61 62
62 63 * ${_('Pull Request')}: !${pull_request.pull_request_id}
63 64
64 65 * ${h.literal(_('Commit flow: {source_ref_type}:{source_ref_name} of {source_repo_url} into {target_ref_type}:{target_ref_name} of {target_repo_url}').format(**data))}
65 66
66 67 %if status_change and not closing_pr:
67 68 * ${_('{user} submitted pull request !{pr_id} status: *{status}*').format(**data)}
68 69
69 70 %elif status_change and closing_pr:
70 71 * ${_('{user} submitted pull request !{pr_id} status: *{status} and closed*').format(**data)}
71 72
72 73 %endif
73 74 %if comment_file:
74 75 * ${_('File: {comment_file} on line {comment_line}').format(**data)}
75 76
76 77 %endif
77 78 % if comment_type == 'todo':
78 79 ${('Inline' if comment_file else 'General')} ${_('`TODO` number')} ${comment_id}:
79 80 % else:
80 81 ${('Inline' if comment_file else 'General')} ${_('`Note` number')} ${comment_id}:
81 82 % endif
82 83
83 84 ${comment_body |n, trim}
84 85
85 86 ---
86 87 ${self.plaintext_footer()}
87 88 </%def>
88 89
89 90
90 91 <%
91 92 data = {
92 93 'user': h.person(user),
93 94 'comment_file': comment_file,
94 95 'comment_line': comment_line,
95 96 'comment_type': comment_type,
96 97 'comment_id': comment_id,
97 98 'renderer_type': renderer_type or 'plain',
98 99
99 100 'pr_title': pull_request.title,
100 101 'pr_id': pull_request.pull_request_id,
101 102 'status': status_change,
102 103 'source_ref_type': pull_request.source_ref_parts.type,
103 104 'source_ref_name': pull_request.source_ref_parts.name,
104 105 'target_ref_type': pull_request.target_ref_parts.type,
105 106 'target_ref_name': pull_request.target_ref_parts.name,
106 107 'source_repo': pull_request_source_repo.repo_name,
107 108 'target_repo': pull_request_target_repo.repo_name,
108 109 'source_repo_url': h.link_to(pull_request_source_repo.repo_name, pull_request_source_repo_url),
109 110 'target_repo_url': h.link_to(pull_request_target_repo.repo_name, pull_request_target_repo_url),
110 111 }
111 112 %>
112 113
113 114 ## header
114 115 <table style="text-align:left;vertical-align:middle;width: 100%">
115 116 <tr>
116 117 <td style="width:100%;border-bottom:1px solid #dbd9da;">
117 118
118 119 <div style="margin: 0; font-weight: bold">
119 120 <div class="clear-both" style="margin-bottom: 4px">
120 121 <span style="color:#7E7F7F">@${h.person(user.username)}</span>
121 122 ${_('left a')}
122 123 <a href="${pr_comment_url}" style="${base.link_css()}">
123 124 % if comment_file:
124 125 ${_('{comment_type} on file `{comment_file}` in pull request.').format(**data)}
125 126 % else:
126 127 ${_('{comment_type} on pull request.').format(**data) |n}
127 128 % endif
128 129 </a>
129 130 </div>
130 131 <div style="margin-top: 10px"></div>
131 132 ${_('Pull request')} <code>!${data['pr_id']}: ${data['pr_title']}</code>
132 133 </div>
133 134
134 135 </td>
135 136 </tr>
136 137
137 138 </table>
138 139 <div class="clear-both"></div>
139 140 ## main body
140 141 <table style="text-align:left;vertical-align:middle;width: 100%">
141 142
142 143 ## spacing def
143 144 <tr>
144 145 <td style="width: 130px"></td>
145 146 <td></td>
146 147 </tr>
147 148
148 149 % if status_change:
149 150 <tr>
150 151 <td style="padding-right:20px;">${_('Review Status')}:</td>
151 152 <td>
152 153 % if closing_pr:
153 154 ${_('Closed pull request with status')}: ${base.status_text(status_change, tag_type=status_change_type)}
154 155 % else:
155 156 ${_('Submitted review status')}: ${base.status_text(status_change, tag_type=status_change_type)}
156 157 % endif
157 158 </td>
158 159 </tr>
159 160 % endif
160 161 <tr>
161 162 <td style="padding-right:20px;">${_('Pull request')}:</td>
162 163 <td>
163 164 <a href="${pull_request_url}" style="${base.link_css()}">
164 165 !${pull_request.pull_request_id}
165 166 </a>
166 167 </td>
167 168 </tr>
168 169
169 170 <tr>
170 171 <td style="padding-right:20px;line-height:20px;">${_('Commit Flow')}:</td>
171 172 <td style="line-height:20px;">
172 173 <code>${'{}:{}'.format(data['source_ref_type'], pull_request.source_ref_parts.name)}</code> ${_('of')} ${data['source_repo_url']}
173 174 &rarr;
174 175 <code>${'{}:{}'.format(data['target_ref_type'], pull_request.target_ref_parts.name)}</code> ${_('of')} ${data['target_repo_url']}
175 176 </td>
176 177 </tr>
177 178
178 179 % if comment_file:
179 180 <tr>
180 181 <td style="padding-right:20px;">${_('File')}:</td>
181 182 <td><a href="${pr_comment_url}" style="${base.link_css()}">${_('`{comment_file}` on line {comment_line}').format(**data)}</a></td>
182 183 </tr>
183 184 % endif
184 185
185 186 <tr style="border-bottom:1px solid #dbd9da;">
186 187 <td colspan="2" style="padding-right:20px;">
187 188 % if comment_type == 'todo':
188 189 ${('Inline' if comment_file else 'General')} ${_('`TODO` number')} ${comment_id}:
189 190 % else:
190 191 ${('Inline' if comment_file else 'General')} ${_('`Note` number')} ${comment_id}:
191 192 % endif
192 193 </td>
193 194 </tr>
194 195
195 196 <tr>
196 197 <td colspan="2" style="background: #F7F7F7">${h.render(comment_body, renderer=data['renderer_type'], mentions=True)}</td>
197 198 </tr>
198 199
199 200 <tr>
200 201 <td><a href="${pr_comment_reply_url}">${_('Reply')}</a></td>
201 202 <td></td>
202 203 </tr>
203 204 </table>
@@ -1,22 +1,18 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3
4 4 <%def name="subject()" filter="n,trim,whitespace_filter">
5 5 Test "Subject" ${_('hello "world"')|n}
6 6 </%def>
7 7
8 <%def name="headers()" filter="n,trim">
9 X=Y
10 </%def>
11
12 8 ## plain text version of the email. Empty by default
13 9 <%def name="body_plaintext()" filter="n,trim">
14 10 Email Plaintext Body
15 11 </%def>
16 12
17 13 ## BODY GOES BELOW
18 14 <strong>Email Body</strong>
19 15 <br/>
20 16 <br/>
21 17 `h.short_id()`: ${h.short_id('0' * 40)}<br/>
22 18 ${_('Translation String')}<br/>
@@ -1,194 +1,190 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 pytest
22 22 import collections
23 23
24 24 from rhodecode.lib.partial_renderer import PyramidPartialRenderer
25 25 from rhodecode.lib.utils2 import AttributeDict
26 26 from rhodecode.model.db import User
27 27 from rhodecode.model.notification import EmailNotificationModel
28 28
29 29
30 30 def test_get_template_obj(app, request_stub):
31 31 template = EmailNotificationModel().get_renderer(
32 32 EmailNotificationModel.TYPE_TEST, request_stub)
33 33 assert isinstance(template, PyramidPartialRenderer)
34 34
35 35
36 36 def test_render_email(app, http_host_only_stub):
37 37 kwargs = {}
38 subject, headers, body, body_plaintext = EmailNotificationModel().render_email(
38 subject, body, body_plaintext = EmailNotificationModel().render_email(
39 39 EmailNotificationModel.TYPE_TEST, **kwargs)
40 40
41 41 # subject
42 42 assert subject == 'Test "Subject" hello "world"'
43 43
44 # headers
45 assert headers == 'X=Y'
46
47 44 # body plaintext
48 45 assert body_plaintext == 'Email Plaintext Body'
49 46
50 47 # body
51 48 notification_footer1 = 'This is a notification from RhodeCode.'
52 49 notification_footer2 = 'http://{}/'.format(http_host_only_stub)
53 50 assert notification_footer1 in body
54 51 assert notification_footer2 in body
55 52 assert 'Email Body' in body
56 53
57 54
58 55 def test_render_pr_email(app, user_admin):
59 56 ref = collections.namedtuple(
60 57 'Ref', 'name, type')('fxies123', 'book')
61 58
62 59 pr = collections.namedtuple('PullRequest',
63 60 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
64 61 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
65 62
66 63 source_repo = target_repo = collections.namedtuple(
67 64 'Repo', 'type, repo_name')('hg', 'pull_request_1')
68 65
69 66 kwargs = {
70 67 'user': User.get_first_super_admin(),
71 68 'pull_request': pr,
72 69 'pull_request_commits': [],
73 70
74 71 'pull_request_target_repo': target_repo,
75 72 'pull_request_target_repo_url': 'x',
76 73
77 74 'pull_request_source_repo': source_repo,
78 75 'pull_request_source_repo_url': 'x',
79 76
80 77 'pull_request_url': 'http://localhost/pr1',
81 78 }
82 79
83 subject, headers, body, body_plaintext = EmailNotificationModel().render_email(
80 subject, body, body_plaintext = EmailNotificationModel().render_email(
84 81 EmailNotificationModel.TYPE_PULL_REQUEST, **kwargs)
85 82
86 83 # subject
87 84 assert subject == '@test_admin (RhodeCode Admin) requested a pull request review. !200: "Example Pull Request"'
88 85
89 86
90 87 def test_render_pr_update_email(app, user_admin):
91 88 ref = collections.namedtuple(
92 89 'Ref', 'name, type')('fxies123', 'book')
93 90
94 91 pr = collections.namedtuple('PullRequest',
95 92 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
96 93 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
97 94
98 95 source_repo = target_repo = collections.namedtuple(
99 96 'Repo', 'type, repo_name')('hg', 'pull_request_1')
100 97
101 98 commit_changes = AttributeDict({
102 99 'added': ['aaaaaaabbbbb', 'cccccccddddddd'],
103 100 'removed': ['eeeeeeeeeee'],
104 101 })
105 102 file_changes = AttributeDict({
106 103 'added': ['a/file1.md', 'file2.py'],
107 104 'modified': ['b/modified_file.rst'],
108 105 'removed': ['.idea'],
109 106 })
110 107
111 108 kwargs = {
112 109 'updating_user': User.get_first_super_admin(),
113 110
114 111 'pull_request': pr,
115 112 'pull_request_commits': [],
116 113
117 114 'pull_request_target_repo': target_repo,
118 115 'pull_request_target_repo_url': 'x',
119 116
120 117 'pull_request_source_repo': source_repo,
121 118 'pull_request_source_repo_url': 'x',
122 119
123 120 'pull_request_url': 'http://localhost/pr1',
124 121
125 122 'pr_comment_url': 'http://comment-url',
126 123 'pr_comment_reply_url': 'http://comment-url#reply',
127 124 'ancestor_commit_id': 'f39bd443',
128 125 'added_commits': commit_changes.added,
129 126 'removed_commits': commit_changes.removed,
130 127 'changed_files': (file_changes.added + file_changes.modified + file_changes.removed),
131 128 'added_files': file_changes.added,
132 129 'modified_files': file_changes.modified,
133 130 'removed_files': file_changes.removed,
134 131 }
135 132
136 subject, headers, body, body_plaintext = EmailNotificationModel().render_email(
133 subject, body, body_plaintext = EmailNotificationModel().render_email(
137 134 EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE, **kwargs)
138 135
139 136 # subject
140 137 assert subject == '@test_admin (RhodeCode Admin) updated pull request. !200: "Example Pull Request"'
141 138
142 139
143 140 @pytest.mark.parametrize('mention', [
144 141 True,
145 142 False
146 143 ])
147 144 @pytest.mark.parametrize('email_type', [
148 145 EmailNotificationModel.TYPE_COMMIT_COMMENT,
149 146 EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
150 147 ])
151 148 def test_render_comment_subject_no_newlines(app, mention, email_type):
152 149 ref = collections.namedtuple(
153 150 'Ref', 'name, type')('fxies123', 'book')
154 151
155 152 pr = collections.namedtuple('PullRequest',
156 153 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
157 154 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
158 155
159 156 source_repo = target_repo = collections.namedtuple(
160 157 'Repo', 'type, repo_name')('hg', 'pull_request_1')
161 158
162 159 kwargs = {
163 160 'user': User.get_first_super_admin(),
164 161 'commit': AttributeDict(raw_id='a'*40, message='Commit message'),
165 162 'status_change': 'approved',
166 163 'commit_target_repo_url': 'http://foo.example.com/#comment1',
167 164 'repo_name': 'test-repo',
168 165 'comment_file': 'test-file.py',
169 166 'comment_line': 'n100',
170 167 'comment_type': 'note',
171 168 'comment_id': 2048,
172 169 'commit_comment_url': 'http://comment-url',
173 170 'commit_comment_reply_url': 'http://comment-url/#Reply',
174 171 'instance_url': 'http://rc-instance',
175 172 'comment_body': 'hello world',
176 173 'mention': mention,
177 174
178 175 'pr_comment_url': 'http://comment-url',
179 176 'pr_comment_reply_url': 'http://comment-url/#Reply',
180 177 'pull_request': pr,
181 178 'pull_request_commits': [],
182 179
183 180 'pull_request_target_repo': target_repo,
184 181 'pull_request_target_repo_url': 'x',
185 182
186 183 'pull_request_source_repo': source_repo,
187 184 'pull_request_source_repo_url': 'x',
188 185
189 186 'pull_request_url': 'http://code.rc.com/_pr/123'
190 187 }
191 subject, headers, body, body_plaintext = EmailNotificationModel().render_email(
192 email_type, **kwargs)
188 subject, body, body_plaintext = EmailNotificationModel().render_email(email_type, **kwargs)
193 189
194 190 assert '\n' not in subject
General Comments 0
You need to be logged in to leave comments. Login now