##// END OF EJS Templates
json: fixed calls to json after orjson implementation
super-admin -
r4974:37813a48 default
parent child Browse files
Show More
@@ -1,1322 +1,1321 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import datetime
23 23 import formencode
24 24 import formencode.htmlfill
25 25
26 26 from pyramid.httpexceptions import HTTPFound
27 27 from pyramid.renderers import render
28 28 from pyramid.response import Response
29 29
30 30 from rhodecode import events
31 31 from rhodecode.apps._base import BaseAppView, DataGridAppView, UserAppView
32 32 from rhodecode.apps.ssh_support import SshKeyFileChangeEvent
33 33 from rhodecode.authentication.base import get_authn_registry, RhodeCodeExternalAuthPlugin
34 34 from rhodecode.authentication.plugins import auth_rhodecode
35 35 from rhodecode.events import trigger
36 36 from rhodecode.model.db import true, UserNotice
37 37
38 38 from rhodecode.lib import audit_logger, rc_cache, auth
39 39 from rhodecode.lib.exceptions import (
40 40 UserCreationError, UserOwnsReposException, UserOwnsRepoGroupsException,
41 41 UserOwnsUserGroupsException, UserOwnsPullRequestsException,
42 42 UserOwnsArtifactsException, DefaultUserException)
43 from rhodecode.lib.ext_json import json
43 from rhodecode.lib import ext_json
44 44 from rhodecode.lib.auth import (
45 45 LoginRequired, HasPermissionAllDecorator, CSRFRequired)
46 46 from rhodecode.lib import helpers as h
47 47 from rhodecode.lib.helpers import SqlPage
48 48 from rhodecode.lib.utils2 import safe_int, safe_unicode, AttributeDict
49 49 from rhodecode.model.auth_token import AuthTokenModel
50 50 from rhodecode.model.forms import (
51 51 UserForm, UserIndividualPermissionsForm, UserPermissionsForm,
52 52 UserExtraEmailForm, UserExtraIpForm)
53 53 from rhodecode.model.permission import PermissionModel
54 54 from rhodecode.model.repo_group import RepoGroupModel
55 55 from rhodecode.model.ssh_key import SshKeyModel
56 56 from rhodecode.model.user import UserModel
57 57 from rhodecode.model.user_group import UserGroupModel
58 58 from rhodecode.model.db import (
59 59 or_, coalesce,IntegrityError, User, UserGroup, UserIpMap, UserEmailMap,
60 60 UserApiKeys, UserSshKeys, RepoGroup)
61 61 from rhodecode.model.meta import Session
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65
66 66 class AdminUsersView(BaseAppView, DataGridAppView):
67 67
68 68 def load_default_context(self):
69 69 c = self._get_local_tmpl_context()
70 70 return c
71 71
72 72 @LoginRequired()
73 73 @HasPermissionAllDecorator('hg.admin')
74 74 def users_list(self):
75 75 c = self.load_default_context()
76 76 return self._get_template_context(c)
77 77
78 78 @LoginRequired()
79 79 @HasPermissionAllDecorator('hg.admin')
80 80 def users_list_data(self):
81 81 self.load_default_context()
82 82 column_map = {
83 83 'first_name': 'name',
84 84 'last_name': 'lastname',
85 85 }
86 86 draw, start, limit = self._extract_chunk(self.request)
87 87 search_q, order_by, order_dir = self._extract_ordering(
88 88 self.request, column_map=column_map)
89 89 _render = self.request.get_partial_renderer(
90 90 'rhodecode:templates/data_table/_dt_elements.mako')
91 91
92 92 def user_actions(user_id, username):
93 93 return _render("user_actions", user_id, username)
94 94
95 95 users_data_total_count = User.query()\
96 96 .filter(User.username != User.DEFAULT_USER) \
97 97 .count()
98 98
99 99 users_data_total_inactive_count = User.query()\
100 100 .filter(User.username != User.DEFAULT_USER) \
101 101 .filter(User.active != true())\
102 102 .count()
103 103
104 104 # json generate
105 105 base_q = User.query().filter(User.username != User.DEFAULT_USER)
106 106 base_inactive_q = base_q.filter(User.active != true())
107 107
108 108 if search_q:
109 109 like_expression = u'%{}%'.format(safe_unicode(search_q))
110 110 base_q = base_q.filter(or_(
111 111 User.username.ilike(like_expression),
112 112 User._email.ilike(like_expression),
113 113 User.name.ilike(like_expression),
114 114 User.lastname.ilike(like_expression),
115 115 ))
116 116 base_inactive_q = base_q.filter(User.active != true())
117 117
118 118 users_data_total_filtered_count = base_q.count()
119 119 users_data_total_filtered_inactive_count = base_inactive_q.count()
120 120
121 121 sort_col = getattr(User, order_by, None)
122 122 if sort_col:
123 123 if order_dir == 'asc':
124 124 # handle null values properly to order by NULL last
125 125 if order_by in ['last_activity']:
126 126 sort_col = coalesce(sort_col, datetime.date.max)
127 127 sort_col = sort_col.asc()
128 128 else:
129 129 # handle null values properly to order by NULL last
130 130 if order_by in ['last_activity']:
131 131 sort_col = coalesce(sort_col, datetime.date.min)
132 132 sort_col = sort_col.desc()
133 133
134 134 base_q = base_q.order_by(sort_col)
135 135 base_q = base_q.offset(start).limit(limit)
136 136
137 137 users_list = base_q.all()
138 138
139 139 users_data = []
140 140 for user in users_list:
141 141 users_data.append({
142 142 "username": h.gravatar_with_user(self.request, user.username),
143 143 "email": user.email,
144 144 "first_name": user.first_name,
145 145 "last_name": user.last_name,
146 146 "last_login": h.format_date(user.last_login),
147 147 "last_activity": h.format_date(user.last_activity),
148 148 "active": h.bool2icon(user.active),
149 149 "active_raw": user.active,
150 150 "admin": h.bool2icon(user.admin),
151 151 "extern_type": user.extern_type,
152 152 "extern_name": user.extern_name,
153 153 "action": user_actions(user.user_id, user.username),
154 154 })
155 155 data = ({
156 156 'draw': draw,
157 157 'data': users_data,
158 158 'recordsTotal': users_data_total_count,
159 159 'recordsFiltered': users_data_total_filtered_count,
160 160 'recordsTotalInactive': users_data_total_inactive_count,
161 161 'recordsFilteredInactive': users_data_total_filtered_inactive_count
162 162 })
163 163
164 164 return data
165 165
166 166 def _set_personal_repo_group_template_vars(self, c_obj):
167 167 DummyUser = AttributeDict({
168 168 'username': '${username}',
169 169 'user_id': '${user_id}',
170 170 })
171 171 c_obj.default_create_repo_group = RepoGroupModel() \
172 172 .get_default_create_personal_repo_group()
173 173 c_obj.personal_repo_group_name = RepoGroupModel() \
174 174 .get_personal_group_name(DummyUser)
175 175
176 176 @LoginRequired()
177 177 @HasPermissionAllDecorator('hg.admin')
178 178 def users_new(self):
179 179 _ = self.request.translate
180 180 c = self.load_default_context()
181 181 c.default_extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid
182 182 self._set_personal_repo_group_template_vars(c)
183 183 return self._get_template_context(c)
184 184
185 185 @LoginRequired()
186 186 @HasPermissionAllDecorator('hg.admin')
187 187 @CSRFRequired()
188 188 def users_create(self):
189 189 _ = self.request.translate
190 190 c = self.load_default_context()
191 191 c.default_extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid
192 192 user_model = UserModel()
193 193 user_form = UserForm(self.request.translate)()
194 194 try:
195 195 form_result = user_form.to_python(dict(self.request.POST))
196 196 user = user_model.create(form_result)
197 197 Session().flush()
198 198 creation_data = user.get_api_data()
199 199 username = form_result['username']
200 200
201 201 audit_logger.store_web(
202 202 'user.create', action_data={'data': creation_data},
203 203 user=c.rhodecode_user)
204 204
205 205 user_link = h.link_to(
206 206 h.escape(username),
207 207 h.route_path('user_edit', user_id=user.user_id))
208 208 h.flash(h.literal(_('Created user %(user_link)s')
209 209 % {'user_link': user_link}), category='success')
210 210 Session().commit()
211 211 except formencode.Invalid as errors:
212 212 self._set_personal_repo_group_template_vars(c)
213 213 data = render(
214 214 'rhodecode:templates/admin/users/user_add.mako',
215 215 self._get_template_context(c), self.request)
216 216 html = formencode.htmlfill.render(
217 217 data,
218 218 defaults=errors.value,
219 219 errors=errors.error_dict or {},
220 220 prefix_error=False,
221 221 encoding="UTF-8",
222 222 force_defaults=False
223 223 )
224 224 return Response(html)
225 225 except UserCreationError as e:
226 226 h.flash(e, 'error')
227 227 except Exception:
228 228 log.exception("Exception creation of user")
229 229 h.flash(_('Error occurred during creation of user %s')
230 230 % self.request.POST.get('username'), category='error')
231 231 raise HTTPFound(h.route_path('users'))
232 232
233 233
234 234 class UsersView(UserAppView):
235 235 ALLOW_SCOPED_TOKENS = False
236 236 """
237 237 This view has alternative version inside EE, if modified please take a look
238 238 in there as well.
239 239 """
240 240
241 241 def get_auth_plugins(self):
242 242 valid_plugins = []
243 243 authn_registry = get_authn_registry(self.request.registry)
244 244 for plugin in authn_registry.get_plugins_for_authentication():
245 245 if isinstance(plugin, RhodeCodeExternalAuthPlugin):
246 246 valid_plugins.append(plugin)
247 247 elif plugin.name == 'rhodecode':
248 248 valid_plugins.append(plugin)
249 249
250 250 # extend our choices if user has set a bound plugin which isn't enabled at the
251 251 # moment
252 252 extern_type = self.db_user.extern_type
253 253 if extern_type not in [x.uid for x in valid_plugins]:
254 254 try:
255 255 plugin = authn_registry.get_plugin_by_uid(extern_type)
256 256 if plugin:
257 257 valid_plugins.append(plugin)
258 258
259 259 except Exception:
260 260 log.exception(
261 261 'Could not extend user plugins with `{}`'.format(extern_type))
262 262 return valid_plugins
263 263
264 264 def load_default_context(self):
265 265 req = self.request
266 266
267 267 c = self._get_local_tmpl_context()
268 268 c.allow_scoped_tokens = self.ALLOW_SCOPED_TOKENS
269 269 c.allowed_languages = [
270 270 ('en', 'English (en)'),
271 271 ('de', 'German (de)'),
272 272 ('fr', 'French (fr)'),
273 273 ('it', 'Italian (it)'),
274 274 ('ja', 'Japanese (ja)'),
275 275 ('pl', 'Polish (pl)'),
276 276 ('pt', 'Portuguese (pt)'),
277 277 ('ru', 'Russian (ru)'),
278 278 ('zh', 'Chinese (zh)'),
279 279 ]
280 280
281 281 c.allowed_extern_types = [
282 282 (x.uid, x.get_display_name()) for x in self.get_auth_plugins()
283 283 ]
284 284 perms = req.registry.settings.get('available_permissions')
285 285 if not perms:
286 286 # inject info about available permissions
287 287 auth.set_available_permissions(req.registry.settings)
288 288
289 289 c.available_permissions = req.registry.settings['available_permissions']
290 290 PermissionModel().set_global_permission_choices(
291 291 c, gettext_translator=req.translate)
292 292
293 293 return c
294 294
295 295 @LoginRequired()
296 296 @HasPermissionAllDecorator('hg.admin')
297 297 @CSRFRequired()
298 298 def user_update(self):
299 299 _ = self.request.translate
300 300 c = self.load_default_context()
301 301
302 302 user_id = self.db_user_id
303 303 c.user = self.db_user
304 304
305 305 c.active = 'profile'
306 306 c.extern_type = c.user.extern_type
307 307 c.extern_name = c.user.extern_name
308 308 c.perm_user = c.user.AuthUser(ip_addr=self.request.remote_addr)
309 309 available_languages = [x[0] for x in c.allowed_languages]
310 310 _form = UserForm(self.request.translate, edit=True,
311 311 available_languages=available_languages,
312 312 old_data={'user_id': user_id,
313 313 'email': c.user.email})()
314 314
315 315 c.edit_mode = self.request.POST.get('edit') == '1'
316 316 form_result = {}
317 317 old_values = c.user.get_api_data()
318 318 try:
319 319 form_result = _form.to_python(dict(self.request.POST))
320 320 skip_attrs = ['extern_name']
321 321 # TODO: plugin should define if username can be updated
322 322
323 323 if c.extern_type != "rhodecode" and not c.edit_mode:
324 324 # forbid updating username for external accounts
325 325 skip_attrs.append('username')
326 326
327 327 UserModel().update_user(
328 328 user_id, skip_attrs=skip_attrs, **form_result)
329 329
330 330 audit_logger.store_web(
331 331 'user.edit', action_data={'old_data': old_values},
332 332 user=c.rhodecode_user)
333 333
334 334 Session().commit()
335 335 h.flash(_('User updated successfully'), category='success')
336 336 except formencode.Invalid as errors:
337 337 data = render(
338 338 'rhodecode:templates/admin/users/user_edit.mako',
339 339 self._get_template_context(c), self.request)
340 340 html = formencode.htmlfill.render(
341 341 data,
342 342 defaults=errors.value,
343 343 errors=errors.error_dict or {},
344 344 prefix_error=False,
345 345 encoding="UTF-8",
346 346 force_defaults=False
347 347 )
348 348 return Response(html)
349 349 except UserCreationError as e:
350 350 h.flash(e, 'error')
351 351 except Exception:
352 352 log.exception("Exception updating user")
353 353 h.flash(_('Error occurred during update of user %s')
354 354 % form_result.get('username'), category='error')
355 355 raise HTTPFound(h.route_path('user_edit', user_id=user_id))
356 356
357 357 @LoginRequired()
358 358 @HasPermissionAllDecorator('hg.admin')
359 359 @CSRFRequired()
360 360 def user_delete(self):
361 361 _ = self.request.translate
362 362 c = self.load_default_context()
363 363 c.user = self.db_user
364 364
365 365 _repos = c.user.repositories
366 366 _repo_groups = c.user.repository_groups
367 367 _user_groups = c.user.user_groups
368 368 _pull_requests = c.user.user_pull_requests
369 369 _artifacts = c.user.artifacts
370 370
371 371 handle_repos = None
372 372 handle_repo_groups = None
373 373 handle_user_groups = None
374 374 handle_pull_requests = None
375 375 handle_artifacts = None
376 376
377 377 # calls for flash of handle based on handle case detach or delete
378 378 def set_handle_flash_repos():
379 379 handle = handle_repos
380 380 if handle == 'detach':
381 381 h.flash(_('Detached %s repositories') % len(_repos),
382 382 category='success')
383 383 elif handle == 'delete':
384 384 h.flash(_('Deleted %s repositories') % len(_repos),
385 385 category='success')
386 386
387 387 def set_handle_flash_repo_groups():
388 388 handle = handle_repo_groups
389 389 if handle == 'detach':
390 390 h.flash(_('Detached %s repository groups') % len(_repo_groups),
391 391 category='success')
392 392 elif handle == 'delete':
393 393 h.flash(_('Deleted %s repository groups') % len(_repo_groups),
394 394 category='success')
395 395
396 396 def set_handle_flash_user_groups():
397 397 handle = handle_user_groups
398 398 if handle == 'detach':
399 399 h.flash(_('Detached %s user groups') % len(_user_groups),
400 400 category='success')
401 401 elif handle == 'delete':
402 402 h.flash(_('Deleted %s user groups') % len(_user_groups),
403 403 category='success')
404 404
405 405 def set_handle_flash_pull_requests():
406 406 handle = handle_pull_requests
407 407 if handle == 'detach':
408 408 h.flash(_('Detached %s pull requests') % len(_pull_requests),
409 409 category='success')
410 410 elif handle == 'delete':
411 411 h.flash(_('Deleted %s pull requests') % len(_pull_requests),
412 412 category='success')
413 413
414 414 def set_handle_flash_artifacts():
415 415 handle = handle_artifacts
416 416 if handle == 'detach':
417 417 h.flash(_('Detached %s artifacts') % len(_artifacts),
418 418 category='success')
419 419 elif handle == 'delete':
420 420 h.flash(_('Deleted %s artifacts') % len(_artifacts),
421 421 category='success')
422 422
423 423 handle_user = User.get_first_super_admin()
424 424 handle_user_id = safe_int(self.request.POST.get('detach_user_id'))
425 425 if handle_user_id:
426 426 # NOTE(marcink): we get new owner for objects...
427 427 handle_user = User.get_or_404(handle_user_id)
428 428
429 429 if _repos and self.request.POST.get('user_repos'):
430 430 handle_repos = self.request.POST['user_repos']
431 431
432 432 if _repo_groups and self.request.POST.get('user_repo_groups'):
433 433 handle_repo_groups = self.request.POST['user_repo_groups']
434 434
435 435 if _user_groups and self.request.POST.get('user_user_groups'):
436 436 handle_user_groups = self.request.POST['user_user_groups']
437 437
438 438 if _pull_requests and self.request.POST.get('user_pull_requests'):
439 439 handle_pull_requests = self.request.POST['user_pull_requests']
440 440
441 441 if _artifacts and self.request.POST.get('user_artifacts'):
442 442 handle_artifacts = self.request.POST['user_artifacts']
443 443
444 444 old_values = c.user.get_api_data()
445 445
446 446 try:
447 447
448 448 UserModel().delete(
449 449 c.user,
450 450 handle_repos=handle_repos,
451 451 handle_repo_groups=handle_repo_groups,
452 452 handle_user_groups=handle_user_groups,
453 453 handle_pull_requests=handle_pull_requests,
454 454 handle_artifacts=handle_artifacts,
455 455 handle_new_owner=handle_user
456 456 )
457 457
458 458 audit_logger.store_web(
459 459 'user.delete', action_data={'old_data': old_values},
460 460 user=c.rhodecode_user)
461 461
462 462 Session().commit()
463 463 set_handle_flash_repos()
464 464 set_handle_flash_repo_groups()
465 465 set_handle_flash_user_groups()
466 466 set_handle_flash_pull_requests()
467 467 set_handle_flash_artifacts()
468 468 username = h.escape(old_values['username'])
469 469 h.flash(_('Successfully deleted user `{}`').format(username), category='success')
470 470 except (UserOwnsReposException, UserOwnsRepoGroupsException,
471 471 UserOwnsUserGroupsException, UserOwnsPullRequestsException,
472 472 UserOwnsArtifactsException, DefaultUserException) as e:
473 473 h.flash(e, category='warning')
474 474 except Exception:
475 475 log.exception("Exception during deletion of user")
476 476 h.flash(_('An error occurred during deletion of user'),
477 477 category='error')
478 478 raise HTTPFound(h.route_path('users'))
479 479
480 480 @LoginRequired()
481 481 @HasPermissionAllDecorator('hg.admin')
482 482 def user_edit(self):
483 483 _ = self.request.translate
484 484 c = self.load_default_context()
485 485 c.user = self.db_user
486 486
487 487 c.active = 'profile'
488 488 c.extern_type = c.user.extern_type
489 489 c.extern_name = c.user.extern_name
490 490 c.perm_user = c.user.AuthUser(ip_addr=self.request.remote_addr)
491 491 c.edit_mode = self.request.GET.get('edit') == '1'
492 492
493 493 defaults = c.user.get_dict()
494 494 defaults.update({'language': c.user.user_data.get('language')})
495 495
496 496 data = render(
497 497 'rhodecode:templates/admin/users/user_edit.mako',
498 498 self._get_template_context(c), self.request)
499 499 html = formencode.htmlfill.render(
500 500 data,
501 501 defaults=defaults,
502 502 encoding="UTF-8",
503 503 force_defaults=False
504 504 )
505 505 return Response(html)
506 506
507 507 @LoginRequired()
508 508 @HasPermissionAllDecorator('hg.admin')
509 509 def user_edit_advanced(self):
510 510 _ = self.request.translate
511 511 c = self.load_default_context()
512 512
513 513 user_id = self.db_user_id
514 514 c.user = self.db_user
515 515
516 516 c.detach_user = User.get_first_super_admin()
517 517 detach_user_id = safe_int(self.request.GET.get('detach_user_id'))
518 518 if detach_user_id:
519 519 c.detach_user = User.get_or_404(detach_user_id)
520 520
521 521 c.active = 'advanced'
522 522 c.personal_repo_group = RepoGroup.get_user_personal_repo_group(user_id)
523 523 c.personal_repo_group_name = RepoGroupModel()\
524 524 .get_personal_group_name(c.user)
525 525
526 526 c.user_to_review_rules = sorted(
527 527 (x.user for x in c.user.user_review_rules),
528 528 key=lambda u: u.username.lower())
529 529
530 530 defaults = c.user.get_dict()
531 531
532 532 # Interim workaround if the user participated on any pull requests as a
533 533 # reviewer.
534 534 has_review = len(c.user.reviewer_pull_requests)
535 535 c.can_delete_user = not has_review
536 536 c.can_delete_user_message = ''
537 537 inactive_link = h.link_to(
538 538 'inactive', h.route_path('user_edit', user_id=user_id, _anchor='active'))
539 539 if has_review == 1:
540 540 c.can_delete_user_message = h.literal(_(
541 541 'The user participates as reviewer in {} pull request and '
542 542 'cannot be deleted. \nYou can set the user to '
543 543 '"{}" instead of deleting it.').format(
544 544 has_review, inactive_link))
545 545 elif has_review:
546 546 c.can_delete_user_message = h.literal(_(
547 547 'The user participates as reviewer in {} pull requests and '
548 548 'cannot be deleted. \nYou can set the user to '
549 549 '"{}" instead of deleting it.').format(
550 550 has_review, inactive_link))
551 551
552 552 data = render(
553 553 'rhodecode:templates/admin/users/user_edit.mako',
554 554 self._get_template_context(c), self.request)
555 555 html = formencode.htmlfill.render(
556 556 data,
557 557 defaults=defaults,
558 558 encoding="UTF-8",
559 559 force_defaults=False
560 560 )
561 561 return Response(html)
562 562
563 563 @LoginRequired()
564 564 @HasPermissionAllDecorator('hg.admin')
565 565 def user_edit_global_perms(self):
566 566 _ = self.request.translate
567 567 c = self.load_default_context()
568 568 c.user = self.db_user
569 569
570 570 c.active = 'global_perms'
571 571
572 572 c.default_user = User.get_default_user()
573 573 defaults = c.user.get_dict()
574 574 defaults.update(c.default_user.get_default_perms(suffix='_inherited'))
575 575 defaults.update(c.default_user.get_default_perms())
576 576 defaults.update(c.user.get_default_perms())
577 577
578 578 data = render(
579 579 'rhodecode:templates/admin/users/user_edit.mako',
580 580 self._get_template_context(c), self.request)
581 581 html = formencode.htmlfill.render(
582 582 data,
583 583 defaults=defaults,
584 584 encoding="UTF-8",
585 585 force_defaults=False
586 586 )
587 587 return Response(html)
588 588
589 589 @LoginRequired()
590 590 @HasPermissionAllDecorator('hg.admin')
591 591 @CSRFRequired()
592 592 def user_edit_global_perms_update(self):
593 593 _ = self.request.translate
594 594 c = self.load_default_context()
595 595
596 596 user_id = self.db_user_id
597 597 c.user = self.db_user
598 598
599 599 c.active = 'global_perms'
600 600 try:
601 601 # first stage that verifies the checkbox
602 602 _form = UserIndividualPermissionsForm(self.request.translate)
603 603 form_result = _form.to_python(dict(self.request.POST))
604 604 inherit_perms = form_result['inherit_default_permissions']
605 605 c.user.inherit_default_permissions = inherit_perms
606 606 Session().add(c.user)
607 607
608 608 if not inherit_perms:
609 609 # only update the individual ones if we un check the flag
610 610 _form = UserPermissionsForm(
611 611 self.request.translate,
612 612 [x[0] for x in c.repo_create_choices],
613 613 [x[0] for x in c.repo_create_on_write_choices],
614 614 [x[0] for x in c.repo_group_create_choices],
615 615 [x[0] for x in c.user_group_create_choices],
616 616 [x[0] for x in c.fork_choices],
617 617 [x[0] for x in c.inherit_default_permission_choices])()
618 618
619 619 form_result = _form.to_python(dict(self.request.POST))
620 620 form_result.update({'perm_user_id': c.user.user_id})
621 621
622 622 PermissionModel().update_user_permissions(form_result)
623 623
624 624 # TODO(marcink): implement global permissions
625 625 # audit_log.store_web('user.edit.permissions')
626 626
627 627 Session().commit()
628 628
629 629 h.flash(_('User global permissions updated successfully'),
630 630 category='success')
631 631
632 632 except formencode.Invalid as errors:
633 633 data = render(
634 634 'rhodecode:templates/admin/users/user_edit.mako',
635 635 self._get_template_context(c), self.request)
636 636 html = formencode.htmlfill.render(
637 637 data,
638 638 defaults=errors.value,
639 639 errors=errors.error_dict or {},
640 640 prefix_error=False,
641 641 encoding="UTF-8",
642 642 force_defaults=False
643 643 )
644 644 return Response(html)
645 645 except Exception:
646 646 log.exception("Exception during permissions saving")
647 647 h.flash(_('An error occurred during permissions saving'),
648 648 category='error')
649 649
650 650 affected_user_ids = [user_id]
651 651 PermissionModel().trigger_permission_flush(affected_user_ids)
652 652 raise HTTPFound(h.route_path('user_edit_global_perms', user_id=user_id))
653 653
654 654 @LoginRequired()
655 655 @HasPermissionAllDecorator('hg.admin')
656 656 @CSRFRequired()
657 657 def user_enable_force_password_reset(self):
658 658 _ = self.request.translate
659 659 c = self.load_default_context()
660 660
661 661 user_id = self.db_user_id
662 662 c.user = self.db_user
663 663
664 664 try:
665 665 c.user.update_userdata(force_password_change=True)
666 666
667 667 msg = _('Force password change enabled for user')
668 668 audit_logger.store_web('user.edit.password_reset.enabled',
669 669 user=c.rhodecode_user)
670 670
671 671 Session().commit()
672 672 h.flash(msg, category='success')
673 673 except Exception:
674 674 log.exception("Exception during password reset for user")
675 675 h.flash(_('An error occurred during password reset for user'),
676 676 category='error')
677 677
678 678 raise HTTPFound(h.route_path('user_edit_advanced', user_id=user_id))
679 679
680 680 @LoginRequired()
681 681 @HasPermissionAllDecorator('hg.admin')
682 682 @CSRFRequired()
683 683 def user_disable_force_password_reset(self):
684 684 _ = self.request.translate
685 685 c = self.load_default_context()
686 686
687 687 user_id = self.db_user_id
688 688 c.user = self.db_user
689 689
690 690 try:
691 691 c.user.update_userdata(force_password_change=False)
692 692
693 693 msg = _('Force password change disabled for user')
694 694 audit_logger.store_web(
695 695 'user.edit.password_reset.disabled',
696 696 user=c.rhodecode_user)
697 697
698 698 Session().commit()
699 699 h.flash(msg, category='success')
700 700 except Exception:
701 701 log.exception("Exception during password reset for user")
702 702 h.flash(_('An error occurred during password reset for user'),
703 703 category='error')
704 704
705 705 raise HTTPFound(h.route_path('user_edit_advanced', user_id=user_id))
706 706
707 707 @LoginRequired()
708 708 @HasPermissionAllDecorator('hg.admin')
709 709 @CSRFRequired()
710 710 def user_notice_dismiss(self):
711 711 _ = self.request.translate
712 712 c = self.load_default_context()
713 713
714 714 user_id = self.db_user_id
715 715 c.user = self.db_user
716 716 user_notice_id = safe_int(self.request.POST.get('notice_id'))
717 717 notice = UserNotice().query()\
718 718 .filter(UserNotice.user_id == user_id)\
719 719 .filter(UserNotice.user_notice_id == user_notice_id)\
720 720 .scalar()
721 721 read = False
722 722 if notice:
723 723 notice.notice_read = True
724 724 Session().add(notice)
725 725 Session().commit()
726 726 read = True
727 727
728 728 return {'notice': user_notice_id, 'read': read}
729 729
730 730 @LoginRequired()
731 731 @HasPermissionAllDecorator('hg.admin')
732 732 @CSRFRequired()
733 733 def user_create_personal_repo_group(self):
734 734 """
735 735 Create personal repository group for this user
736 736 """
737 737 from rhodecode.model.repo_group import RepoGroupModel
738 738
739 739 _ = self.request.translate
740 740 c = self.load_default_context()
741 741
742 742 user_id = self.db_user_id
743 743 c.user = self.db_user
744 744
745 745 personal_repo_group = RepoGroup.get_user_personal_repo_group(
746 746 c.user.user_id)
747 747 if personal_repo_group:
748 748 raise HTTPFound(h.route_path('user_edit_advanced', user_id=user_id))
749 749
750 750 personal_repo_group_name = RepoGroupModel().get_personal_group_name(c.user)
751 751 named_personal_group = RepoGroup.get_by_group_name(
752 752 personal_repo_group_name)
753 753 try:
754 754
755 755 if named_personal_group and named_personal_group.user_id == c.user.user_id:
756 756 # migrate the same named group, and mark it as personal
757 757 named_personal_group.personal = True
758 758 Session().add(named_personal_group)
759 759 Session().commit()
760 760 msg = _('Linked repository group `%s` as personal' % (
761 761 personal_repo_group_name,))
762 762 h.flash(msg, category='success')
763 763 elif not named_personal_group:
764 764 RepoGroupModel().create_personal_repo_group(c.user)
765 765
766 766 msg = _('Created repository group `%s`' % (
767 767 personal_repo_group_name,))
768 768 h.flash(msg, category='success')
769 769 else:
770 770 msg = _('Repository group `%s` is already taken' % (
771 771 personal_repo_group_name,))
772 772 h.flash(msg, category='warning')
773 773 except Exception:
774 774 log.exception("Exception during repository group creation")
775 775 msg = _(
776 776 'An error occurred during repository group creation for user')
777 777 h.flash(msg, category='error')
778 778 Session().rollback()
779 779
780 780 raise HTTPFound(h.route_path('user_edit_advanced', user_id=user_id))
781 781
782 782 @LoginRequired()
783 783 @HasPermissionAllDecorator('hg.admin')
784 784 def auth_tokens(self):
785 785 _ = self.request.translate
786 786 c = self.load_default_context()
787 787 c.user = self.db_user
788 788
789 789 c.active = 'auth_tokens'
790 790
791 791 c.lifetime_values = AuthTokenModel.get_lifetime_values(translator=_)
792 792 c.role_values = [
793 793 (x, AuthTokenModel.cls._get_role_name(x))
794 794 for x in AuthTokenModel.cls.ROLES]
795 795 c.role_options = [(c.role_values, _("Role"))]
796 796 c.user_auth_tokens = AuthTokenModel().get_auth_tokens(
797 797 c.user.user_id, show_expired=True)
798 798 c.role_vcs = AuthTokenModel.cls.ROLE_VCS
799 799 return self._get_template_context(c)
800 800
801 801 @LoginRequired()
802 802 @HasPermissionAllDecorator('hg.admin')
803 803 def auth_tokens_view(self):
804 804 _ = self.request.translate
805 805 c = self.load_default_context()
806 806 c.user = self.db_user
807 807
808 808 auth_token_id = self.request.POST.get('auth_token_id')
809 809
810 810 if auth_token_id:
811 811 token = UserApiKeys.get_or_404(auth_token_id)
812 812
813 813 return {
814 814 'auth_token': token.api_key
815 815 }
816 816
817 817 def maybe_attach_token_scope(self, token):
818 818 # implemented in EE edition
819 819 pass
820 820
821 821 @LoginRequired()
822 822 @HasPermissionAllDecorator('hg.admin')
823 823 @CSRFRequired()
824 824 def auth_tokens_add(self):
825 825 _ = self.request.translate
826 826 c = self.load_default_context()
827 827
828 828 user_id = self.db_user_id
829 829 c.user = self.db_user
830 830
831 831 user_data = c.user.get_api_data()
832 832 lifetime = safe_int(self.request.POST.get('lifetime'), -1)
833 833 description = self.request.POST.get('description')
834 834 role = self.request.POST.get('role')
835 835
836 836 token = UserModel().add_auth_token(
837 837 user=c.user.user_id,
838 838 lifetime_minutes=lifetime, role=role, description=description,
839 839 scope_callback=self.maybe_attach_token_scope)
840 840 token_data = token.get_api_data()
841 841
842 842 audit_logger.store_web(
843 843 'user.edit.token.add', action_data={
844 844 'data': {'token': token_data, 'user': user_data}},
845 845 user=self._rhodecode_user, )
846 846 Session().commit()
847 847
848 848 h.flash(_("Auth token successfully created"), category='success')
849 849 return HTTPFound(h.route_path('edit_user_auth_tokens', user_id=user_id))
850 850
851 851 @LoginRequired()
852 852 @HasPermissionAllDecorator('hg.admin')
853 853 @CSRFRequired()
854 854 def auth_tokens_delete(self):
855 855 _ = self.request.translate
856 856 c = self.load_default_context()
857 857
858 858 user_id = self.db_user_id
859 859 c.user = self.db_user
860 860
861 861 user_data = c.user.get_api_data()
862 862
863 863 del_auth_token = self.request.POST.get('del_auth_token')
864 864
865 865 if del_auth_token:
866 866 token = UserApiKeys.get_or_404(del_auth_token)
867 867 token_data = token.get_api_data()
868 868
869 869 AuthTokenModel().delete(del_auth_token, c.user.user_id)
870 870 audit_logger.store_web(
871 871 'user.edit.token.delete', action_data={
872 872 'data': {'token': token_data, 'user': user_data}},
873 873 user=self._rhodecode_user,)
874 874 Session().commit()
875 875 h.flash(_("Auth token successfully deleted"), category='success')
876 876
877 877 return HTTPFound(h.route_path('edit_user_auth_tokens', user_id=user_id))
878 878
879 879 @LoginRequired()
880 880 @HasPermissionAllDecorator('hg.admin')
881 881 def ssh_keys(self):
882 882 _ = self.request.translate
883 883 c = self.load_default_context()
884 884 c.user = self.db_user
885 885
886 886 c.active = 'ssh_keys'
887 887 c.default_key = self.request.GET.get('default_key')
888 888 c.user_ssh_keys = SshKeyModel().get_ssh_keys(c.user.user_id)
889 889 return self._get_template_context(c)
890 890
891 891 @LoginRequired()
892 892 @HasPermissionAllDecorator('hg.admin')
893 893 def ssh_keys_generate_keypair(self):
894 894 _ = self.request.translate
895 895 c = self.load_default_context()
896 896
897 897 c.user = self.db_user
898 898
899 899 c.active = 'ssh_keys_generate'
900 900 comment = 'RhodeCode-SSH {}'.format(c.user.email or '')
901 901 private_format = self.request.GET.get('private_format') \
902 902 or SshKeyModel.DEFAULT_PRIVATE_KEY_FORMAT
903 903 c.private, c.public = SshKeyModel().generate_keypair(
904 904 comment=comment, private_format=private_format)
905 905
906 906 return self._get_template_context(c)
907 907
908 908 @LoginRequired()
909 909 @HasPermissionAllDecorator('hg.admin')
910 910 @CSRFRequired()
911 911 def ssh_keys_add(self):
912 912 _ = self.request.translate
913 913 c = self.load_default_context()
914 914
915 915 user_id = self.db_user_id
916 916 c.user = self.db_user
917 917
918 918 user_data = c.user.get_api_data()
919 919 key_data = self.request.POST.get('key_data')
920 920 description = self.request.POST.get('description')
921 921
922 922 fingerprint = 'unknown'
923 923 try:
924 924 if not key_data:
925 925 raise ValueError('Please add a valid public key')
926 926
927 927 key = SshKeyModel().parse_key(key_data.strip())
928 928 fingerprint = key.hash_md5()
929 929
930 930 ssh_key = SshKeyModel().create(
931 931 c.user.user_id, fingerprint, key.keydata, description)
932 932 ssh_key_data = ssh_key.get_api_data()
933 933
934 934 audit_logger.store_web(
935 935 'user.edit.ssh_key.add', action_data={
936 936 'data': {'ssh_key': ssh_key_data, 'user': user_data}},
937 937 user=self._rhodecode_user, )
938 938 Session().commit()
939 939
940 940 # Trigger an event on change of keys.
941 941 trigger(SshKeyFileChangeEvent(), self.request.registry)
942 942
943 943 h.flash(_("Ssh Key successfully created"), category='success')
944 944
945 945 except IntegrityError:
946 946 log.exception("Exception during ssh key saving")
947 947 err = 'Such key with fingerprint `{}` already exists, ' \
948 948 'please use a different one'.format(fingerprint)
949 949 h.flash(_('An error occurred during ssh key saving: {}').format(err),
950 950 category='error')
951 951 except Exception as e:
952 952 log.exception("Exception during ssh key saving")
953 953 h.flash(_('An error occurred during ssh key saving: {}').format(e),
954 954 category='error')
955 955
956 956 return HTTPFound(
957 957 h.route_path('edit_user_ssh_keys', user_id=user_id))
958 958
959 959 @LoginRequired()
960 960 @HasPermissionAllDecorator('hg.admin')
961 961 @CSRFRequired()
962 962 def ssh_keys_delete(self):
963 963 _ = self.request.translate
964 964 c = self.load_default_context()
965 965
966 966 user_id = self.db_user_id
967 967 c.user = self.db_user
968 968
969 969 user_data = c.user.get_api_data()
970 970
971 971 del_ssh_key = self.request.POST.get('del_ssh_key')
972 972
973 973 if del_ssh_key:
974 974 ssh_key = UserSshKeys.get_or_404(del_ssh_key)
975 975 ssh_key_data = ssh_key.get_api_data()
976 976
977 977 SshKeyModel().delete(del_ssh_key, c.user.user_id)
978 978 audit_logger.store_web(
979 979 'user.edit.ssh_key.delete', action_data={
980 980 'data': {'ssh_key': ssh_key_data, 'user': user_data}},
981 981 user=self._rhodecode_user,)
982 982 Session().commit()
983 983 # Trigger an event on change of keys.
984 984 trigger(SshKeyFileChangeEvent(), self.request.registry)
985 985 h.flash(_("Ssh key successfully deleted"), category='success')
986 986
987 987 return HTTPFound(h.route_path('edit_user_ssh_keys', user_id=user_id))
988 988
989 989 @LoginRequired()
990 990 @HasPermissionAllDecorator('hg.admin')
991 991 def emails(self):
992 992 _ = self.request.translate
993 993 c = self.load_default_context()
994 994 c.user = self.db_user
995 995
996 996 c.active = 'emails'
997 997 c.user_email_map = UserEmailMap.query() \
998 998 .filter(UserEmailMap.user == c.user).all()
999 999
1000 1000 return self._get_template_context(c)
1001 1001
1002 1002 @LoginRequired()
1003 1003 @HasPermissionAllDecorator('hg.admin')
1004 1004 @CSRFRequired()
1005 1005 def emails_add(self):
1006 1006 _ = self.request.translate
1007 1007 c = self.load_default_context()
1008 1008
1009 1009 user_id = self.db_user_id
1010 1010 c.user = self.db_user
1011 1011
1012 1012 email = self.request.POST.get('new_email')
1013 1013 user_data = c.user.get_api_data()
1014 1014 try:
1015 1015
1016 1016 form = UserExtraEmailForm(self.request.translate)()
1017 1017 data = form.to_python({'email': email})
1018 1018 email = data['email']
1019 1019
1020 1020 UserModel().add_extra_email(c.user.user_id, email)
1021 1021 audit_logger.store_web(
1022 1022 'user.edit.email.add',
1023 1023 action_data={'email': email, 'user': user_data},
1024 1024 user=self._rhodecode_user)
1025 1025 Session().commit()
1026 1026 h.flash(_("Added new email address `%s` for user account") % email,
1027 1027 category='success')
1028 1028 except formencode.Invalid as error:
1029 1029 h.flash(h.escape(error.error_dict['email']), category='error')
1030 1030 except IntegrityError:
1031 1031 log.warning("Email %s already exists", email)
1032 1032 h.flash(_('Email `{}` is already registered for another user.').format(email),
1033 1033 category='error')
1034 1034 except Exception:
1035 1035 log.exception("Exception during email saving")
1036 1036 h.flash(_('An error occurred during email saving'),
1037 1037 category='error')
1038 1038 raise HTTPFound(h.route_path('edit_user_emails', user_id=user_id))
1039 1039
1040 1040 @LoginRequired()
1041 1041 @HasPermissionAllDecorator('hg.admin')
1042 1042 @CSRFRequired()
1043 1043 def emails_delete(self):
1044 1044 _ = self.request.translate
1045 1045 c = self.load_default_context()
1046 1046
1047 1047 user_id = self.db_user_id
1048 1048 c.user = self.db_user
1049 1049
1050 1050 email_id = self.request.POST.get('del_email_id')
1051 1051 user_model = UserModel()
1052 1052
1053 1053 email = UserEmailMap.query().get(email_id).email
1054 1054 user_data = c.user.get_api_data()
1055 1055 user_model.delete_extra_email(c.user.user_id, email_id)
1056 1056 audit_logger.store_web(
1057 1057 'user.edit.email.delete',
1058 1058 action_data={'email': email, 'user': user_data},
1059 1059 user=self._rhodecode_user)
1060 1060 Session().commit()
1061 1061 h.flash(_("Removed email address from user account"),
1062 1062 category='success')
1063 1063 raise HTTPFound(h.route_path('edit_user_emails', user_id=user_id))
1064 1064
1065 1065 @LoginRequired()
1066 1066 @HasPermissionAllDecorator('hg.admin')
1067 1067 def ips(self):
1068 1068 _ = self.request.translate
1069 1069 c = self.load_default_context()
1070 1070 c.user = self.db_user
1071 1071
1072 1072 c.active = 'ips'
1073 1073 c.user_ip_map = UserIpMap.query() \
1074 1074 .filter(UserIpMap.user == c.user).all()
1075 1075
1076 1076 c.inherit_default_ips = c.user.inherit_default_permissions
1077 1077 c.default_user_ip_map = UserIpMap.query() \
1078 1078 .filter(UserIpMap.user == User.get_default_user()).all()
1079 1079
1080 1080 return self._get_template_context(c)
1081 1081
1082 1082 @LoginRequired()
1083 1083 @HasPermissionAllDecorator('hg.admin')
1084 1084 @CSRFRequired()
1085 1085 # NOTE(marcink): this view is allowed for default users, as we can
1086 1086 # edit their IP white list
1087 1087 def ips_add(self):
1088 1088 _ = self.request.translate
1089 1089 c = self.load_default_context()
1090 1090
1091 1091 user_id = self.db_user_id
1092 1092 c.user = self.db_user
1093 1093
1094 1094 user_model = UserModel()
1095 1095 desc = self.request.POST.get('description')
1096 1096 try:
1097 1097 ip_list = user_model.parse_ip_range(
1098 1098 self.request.POST.get('new_ip'))
1099 1099 except Exception as e:
1100 1100 ip_list = []
1101 1101 log.exception("Exception during ip saving")
1102 1102 h.flash(_('An error occurred during ip saving:%s' % (e,)),
1103 1103 category='error')
1104 1104 added = []
1105 1105 user_data = c.user.get_api_data()
1106 1106 for ip in ip_list:
1107 1107 try:
1108 1108 form = UserExtraIpForm(self.request.translate)()
1109 1109 data = form.to_python({'ip': ip})
1110 1110 ip = data['ip']
1111 1111
1112 1112 user_model.add_extra_ip(c.user.user_id, ip, desc)
1113 1113 audit_logger.store_web(
1114 1114 'user.edit.ip.add',
1115 1115 action_data={'ip': ip, 'user': user_data},
1116 1116 user=self._rhodecode_user)
1117 1117 Session().commit()
1118 1118 added.append(ip)
1119 1119 except formencode.Invalid as error:
1120 1120 msg = error.error_dict['ip']
1121 1121 h.flash(msg, category='error')
1122 1122 except Exception:
1123 1123 log.exception("Exception during ip saving")
1124 1124 h.flash(_('An error occurred during ip saving'),
1125 1125 category='error')
1126 1126 if added:
1127 1127 h.flash(
1128 1128 _("Added ips %s to user whitelist") % (', '.join(ip_list), ),
1129 1129 category='success')
1130 1130 if 'default_user' in self.request.POST:
1131 1131 # case for editing global IP list we do it for 'DEFAULT' user
1132 1132 raise HTTPFound(h.route_path('admin_permissions_ips'))
1133 1133 raise HTTPFound(h.route_path('edit_user_ips', user_id=user_id))
1134 1134
1135 1135 @LoginRequired()
1136 1136 @HasPermissionAllDecorator('hg.admin')
1137 1137 @CSRFRequired()
1138 1138 # NOTE(marcink): this view is allowed for default users, as we can
1139 1139 # edit their IP white list
1140 1140 def ips_delete(self):
1141 1141 _ = self.request.translate
1142 1142 c = self.load_default_context()
1143 1143
1144 1144 user_id = self.db_user_id
1145 1145 c.user = self.db_user
1146 1146
1147 1147 ip_id = self.request.POST.get('del_ip_id')
1148 1148 user_model = UserModel()
1149 1149 user_data = c.user.get_api_data()
1150 1150 ip = UserIpMap.query().get(ip_id).ip_addr
1151 1151 user_model.delete_extra_ip(c.user.user_id, ip_id)
1152 1152 audit_logger.store_web(
1153 1153 'user.edit.ip.delete', action_data={'ip': ip, 'user': user_data},
1154 1154 user=self._rhodecode_user)
1155 1155 Session().commit()
1156 1156 h.flash(_("Removed ip address from user whitelist"), category='success')
1157 1157
1158 1158 if 'default_user' in self.request.POST:
1159 1159 # case for editing global IP list we do it for 'DEFAULT' user
1160 1160 raise HTTPFound(h.route_path('admin_permissions_ips'))
1161 1161 raise HTTPFound(h.route_path('edit_user_ips', user_id=user_id))
1162 1162
1163 1163 @LoginRequired()
1164 1164 @HasPermissionAllDecorator('hg.admin')
1165 1165 def groups_management(self):
1166 1166 c = self.load_default_context()
1167 1167 c.user = self.db_user
1168 1168 c.data = c.user.group_member
1169 1169
1170 1170 groups = [UserGroupModel.get_user_groups_as_dict(group.users_group)
1171 1171 for group in c.user.group_member]
1172 c.groups = json.dumps(groups)
1172 c.groups = ext_json.str_json(groups)
1173 1173 c.active = 'groups'
1174 1174
1175 1175 return self._get_template_context(c)
1176 1176
1177 1177 @LoginRequired()
1178 1178 @HasPermissionAllDecorator('hg.admin')
1179 1179 @CSRFRequired()
1180 1180 def groups_management_updates(self):
1181 1181 _ = self.request.translate
1182 1182 c = self.load_default_context()
1183 1183
1184 1184 user_id = self.db_user_id
1185 1185 c.user = self.db_user
1186 1186
1187 1187 user_groups = set(self.request.POST.getall('users_group_id'))
1188 1188 user_groups_objects = []
1189 1189
1190 1190 for ugid in user_groups:
1191 1191 user_groups_objects.append(
1192 1192 UserGroupModel().get_group(safe_int(ugid)))
1193 1193 user_group_model = UserGroupModel()
1194 1194 added_to_groups, removed_from_groups = \
1195 1195 user_group_model.change_groups(c.user, user_groups_objects)
1196 1196
1197 1197 user_data = c.user.get_api_data()
1198 1198 for user_group_id in added_to_groups:
1199 1199 user_group = UserGroup.get(user_group_id)
1200 1200 old_values = user_group.get_api_data()
1201 1201 audit_logger.store_web(
1202 1202 'user_group.edit.member.add',
1203 1203 action_data={'user': user_data, 'old_data': old_values},
1204 1204 user=self._rhodecode_user)
1205 1205
1206 1206 for user_group_id in removed_from_groups:
1207 1207 user_group = UserGroup.get(user_group_id)
1208 1208 old_values = user_group.get_api_data()
1209 1209 audit_logger.store_web(
1210 1210 'user_group.edit.member.delete',
1211 1211 action_data={'user': user_data, 'old_data': old_values},
1212 1212 user=self._rhodecode_user)
1213 1213
1214 1214 Session().commit()
1215 1215 c.active = 'user_groups_management'
1216 1216 h.flash(_("Groups successfully changed"), category='success')
1217 1217
1218 1218 return HTTPFound(h.route_path(
1219 1219 'edit_user_groups_management', user_id=user_id))
1220 1220
1221 1221 @LoginRequired()
1222 1222 @HasPermissionAllDecorator('hg.admin')
1223 1223 def user_audit_logs(self):
1224 1224 _ = self.request.translate
1225 1225 c = self.load_default_context()
1226 1226 c.user = self.db_user
1227 1227
1228 1228 c.active = 'audit'
1229 1229
1230 1230 p = safe_int(self.request.GET.get('page', 1), 1)
1231 1231
1232 1232 filter_term = self.request.GET.get('filter')
1233 1233 user_log = UserModel().get_user_log(c.user, filter_term)
1234 1234
1235 1235 def url_generator(page_num):
1236 1236 query_params = {
1237 1237 'page': page_num
1238 1238 }
1239 1239 if filter_term:
1240 1240 query_params['filter'] = filter_term
1241 1241 return self.request.current_route_path(_query=query_params)
1242 1242
1243 1243 c.audit_logs = SqlPage(
1244 1244 user_log, page=p, items_per_page=10, url_maker=url_generator)
1245 1245 c.filter_term = filter_term
1246 1246 return self._get_template_context(c)
1247 1247
1248 1248 @LoginRequired()
1249 1249 @HasPermissionAllDecorator('hg.admin')
1250 1250 def user_audit_logs_download(self):
1251 1251 _ = self.request.translate
1252 1252 c = self.load_default_context()
1253 1253 c.user = self.db_user
1254 1254
1255 1255 user_log = UserModel().get_user_log(c.user, filter_term=None)
1256 1256
1257 1257 audit_log_data = {}
1258 1258 for entry in user_log:
1259 1259 audit_log_data[entry.user_log_id] = entry.get_dict()
1260 1260
1261 response = Response(json.dumps(audit_log_data, indent=4))
1262 response.content_disposition = str(
1263 'attachment; filename=%s' % 'user_{}_audit_logs.json'.format(c.user.user_id))
1261 response = Response(ext_json.formatted_str_json(audit_log_data))
1262 response.content_disposition = f'attachment; filename=user_{c.user.user_id}_audit_logs.json'
1264 1263 response.content_type = 'application/json'
1265 1264
1266 1265 return response
1267 1266
1268 1267 @LoginRequired()
1269 1268 @HasPermissionAllDecorator('hg.admin')
1270 1269 def user_perms_summary(self):
1271 1270 _ = self.request.translate
1272 1271 c = self.load_default_context()
1273 1272 c.user = self.db_user
1274 1273
1275 1274 c.active = 'perms_summary'
1276 1275 c.perm_user = c.user.AuthUser(ip_addr=self.request.remote_addr)
1277 1276
1278 1277 return self._get_template_context(c)
1279 1278
1280 1279 @LoginRequired()
1281 1280 @HasPermissionAllDecorator('hg.admin')
1282 1281 def user_perms_summary_json(self):
1283 1282 self.load_default_context()
1284 1283 perm_user = self.db_user.AuthUser(ip_addr=self.request.remote_addr)
1285 1284
1286 1285 return perm_user.permissions
1287 1286
1288 1287 @LoginRequired()
1289 1288 @HasPermissionAllDecorator('hg.admin')
1290 1289 def user_caches(self):
1291 1290 _ = self.request.translate
1292 1291 c = self.load_default_context()
1293 1292 c.user = self.db_user
1294 1293
1295 1294 c.active = 'caches'
1296 1295 c.perm_user = c.user.AuthUser(ip_addr=self.request.remote_addr)
1297 1296
1298 1297 cache_namespace_uid = 'cache_user_auth.{}'.format(self.db_user.user_id)
1299 1298 c.region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)
1300 1299 c.backend = c.region.backend
1301 1300 c.user_keys = sorted(c.region.backend.list_keys(prefix=cache_namespace_uid))
1302 1301
1303 1302 return self._get_template_context(c)
1304 1303
1305 1304 @LoginRequired()
1306 1305 @HasPermissionAllDecorator('hg.admin')
1307 1306 @CSRFRequired()
1308 1307 def user_caches_update(self):
1309 1308 _ = self.request.translate
1310 1309 c = self.load_default_context()
1311 1310 c.user = self.db_user
1312 1311
1313 1312 c.active = 'caches'
1314 1313 c.perm_user = c.user.AuthUser(ip_addr=self.request.remote_addr)
1315 1314
1316 1315 cache_namespace_uid = 'cache_user_auth.{}'.format(self.db_user.user_id)
1317 1316 del_keys = rc_cache.clear_cache_namespace('cache_perms', cache_namespace_uid)
1318 1317
1319 1318 h.flash(_("Deleted {} cache keys").format(del_keys), category='success')
1320 1319
1321 1320 return HTTPFound(h.route_path(
1322 1321 'edit_user_caches', user_id=c.user.user_id))
@@ -1,106 +1,107 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
23 23 from pyramid.events import ApplicationCreated
24 24 from pyramid.settings import asbool
25 25
26 26 from rhodecode.apps._base import ADMIN_PREFIX
27 27 from rhodecode.lib.ext_json import json
28 from rhodecode.lib.str_utils import safe_str
28 29
29 30
30 31 def url_gen(request):
31 32 registry = request.registry
32 33 longpoll_url = registry.settings.get('channelstream.longpoll_url', '')
33 34 ws_url = registry.settings.get('channelstream.ws_url', '')
34 35 proxy_url = request.route_url('channelstream_proxy')
35 36 urls = {
36 37 'connect': request.route_path('channelstream_connect'),
37 38 'subscribe': request.route_path('channelstream_subscribe'),
38 39 'longpoll': longpoll_url or proxy_url,
39 40 'ws': ws_url or proxy_url.replace('http', 'ws')
40 41 }
41 return json.dumps(urls)
42 return safe_str(json.dumps(urls))
42 43
43 44
44 45 PLUGIN_DEFINITION = {
45 46 'name': 'channelstream',
46 47 'config': {
47 48 'javascript': [],
48 49 'css': [],
49 50 'template_hooks': {
50 51 'plugin_init_template': 'rhodecode:templates/channelstream/plugin_init.mako'
51 52 },
52 53 'url_gen': url_gen,
53 54 'static': None,
54 55 'enabled': False,
55 56 'server': '',
56 57 'secret': ''
57 58 }
58 59 }
59 60
60 61
61 62 def maybe_create_history_store(event):
62 63 # create plugin history location
63 64 settings = event.app.registry.settings
64 65 history_dir = settings.get('channelstream.history.location', '')
65 66 if history_dir and not os.path.exists(history_dir):
66 67 os.makedirs(history_dir, 0o750)
67 68
68 69
69 70 def includeme(config):
70 71 from rhodecode.apps.channelstream.views import ChannelstreamView
71 72
72 73 settings = config.registry.settings
73 74 PLUGIN_DEFINITION['config']['enabled'] = asbool(
74 75 settings.get('channelstream.enabled'))
75 76 PLUGIN_DEFINITION['config']['server'] = settings.get(
76 77 'channelstream.server', '')
77 78 PLUGIN_DEFINITION['config']['secret'] = settings.get(
78 79 'channelstream.secret', '')
79 80 PLUGIN_DEFINITION['config']['history.location'] = settings.get(
80 81 'channelstream.history.location', '')
81 82 config.register_rhodecode_plugin(
82 83 PLUGIN_DEFINITION['name'],
83 84 PLUGIN_DEFINITION['config']
84 85 )
85 86 config.add_subscriber(maybe_create_history_store, ApplicationCreated)
86 87
87 88 config.add_route(
88 89 name='channelstream_connect',
89 90 pattern=ADMIN_PREFIX + '/channelstream/connect')
90 91 config.add_view(
91 92 ChannelstreamView,
92 93 attr='channelstream_connect',
93 94 route_name='channelstream_connect', renderer='json_ext')
94 95
95 96 config.add_route(
96 97 name='channelstream_subscribe',
97 98 pattern=ADMIN_PREFIX + '/channelstream/subscribe')
98 99 config.add_view(
99 100 ChannelstreamView,
100 101 attr='channelstream_subscribe',
101 102 route_name='channelstream_subscribe', renderer='json_ext')
102 103
103 104 config.add_route(
104 105 name='channelstream_proxy',
105 106 pattern=settings.get('channelstream.proxy_path') or '/_channelstream')
106 107
@@ -1,386 +1,386 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2013-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 time
22 22 import logging
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27
28 28 from pyramid.httpexceptions import HTTPNotFound, HTTPFound, HTTPBadRequest
29 29 from pyramid.renderers import render
30 30 from pyramid.response import Response
31 31
32 32 from rhodecode.apps._base import BaseAppView
33 from rhodecode.lib import helpers as h
33 from rhodecode.lib import helpers as h, ext_json
34 34 from rhodecode.lib.auth import LoginRequired, NotAnonymous, CSRFRequired
35 35 from rhodecode.lib.utils2 import time_to_datetime
36 36 from rhodecode.lib.ext_json import json
37 37 from rhodecode.lib.vcs.exceptions import VCSError, NodeNotChangedError
38 38 from rhodecode.model.gist import GistModel
39 39 from rhodecode.model.meta import Session
40 40 from rhodecode.model.db import Gist, User, or_
41 41 from rhodecode.model import validation_schema
42 42 from rhodecode.model.validation_schema.schemas import gist_schema
43 43
44 44
45 45 log = logging.getLogger(__name__)
46 46
47 47
48 48 class GistView(BaseAppView):
49 49
50 50 def load_default_context(self):
51 51 _ = self.request.translate
52 52 c = self._get_local_tmpl_context()
53 53 c.user = c.auth_user.get_instance()
54 54
55 55 c.lifetime_values = [
56 56 (-1, _('forever')),
57 57 (5, _('5 minutes')),
58 58 (60, _('1 hour')),
59 59 (60 * 24, _('1 day')),
60 60 (60 * 24 * 30, _('1 month')),
61 61 ]
62 62
63 63 c.lifetime_options = [(c.lifetime_values, _("Lifetime"))]
64 64 c.acl_options = [
65 65 (Gist.ACL_LEVEL_PRIVATE, _("Requires registered account")),
66 66 (Gist.ACL_LEVEL_PUBLIC, _("Can be accessed by anonymous users"))
67 67 ]
68 68
69 69 return c
70 70
71 71 @LoginRequired()
72 72 def gist_show_all(self):
73 73 c = self.load_default_context()
74 74
75 75 not_default_user = self._rhodecode_user.username != User.DEFAULT_USER
76 76 c.show_private = self.request.GET.get('private') and not_default_user
77 77 c.show_public = self.request.GET.get('public') and not_default_user
78 78 c.show_all = self.request.GET.get('all') and self._rhodecode_user.admin
79 79
80 80 gists = _gists = Gist().query()\
81 81 .filter(or_(Gist.gist_expires == -1, Gist.gist_expires >= time.time()))\
82 82 .order_by(Gist.created_on.desc())
83 83
84 84 c.active = 'public'
85 85 # MY private
86 86 if c.show_private and not c.show_public:
87 87 gists = _gists.filter(Gist.gist_type == Gist.GIST_PRIVATE)\
88 88 .filter(Gist.gist_owner == self._rhodecode_user.user_id)
89 89 c.active = 'my_private'
90 90 # MY public
91 91 elif c.show_public and not c.show_private:
92 92 gists = _gists.filter(Gist.gist_type == Gist.GIST_PUBLIC)\
93 93 .filter(Gist.gist_owner == self._rhodecode_user.user_id)
94 94 c.active = 'my_public'
95 95 # MY public+private
96 96 elif c.show_private and c.show_public:
97 97 gists = _gists.filter(or_(Gist.gist_type == Gist.GIST_PUBLIC,
98 98 Gist.gist_type == Gist.GIST_PRIVATE))\
99 99 .filter(Gist.gist_owner == self._rhodecode_user.user_id)
100 100 c.active = 'my_all'
101 101 # Show all by super-admin
102 102 elif c.show_all:
103 103 c.active = 'all'
104 104 gists = _gists
105 105
106 106 # default show ALL public gists
107 107 if not c.show_public and not c.show_private and not c.show_all:
108 108 gists = _gists.filter(Gist.gist_type == Gist.GIST_PUBLIC)
109 109 c.active = 'public'
110 110
111 111 _render = self.request.get_partial_renderer(
112 112 'rhodecode:templates/data_table/_dt_elements.mako')
113 113
114 114 data = []
115 115
116 116 for gist in gists:
117 117 data.append({
118 118 'created_on': _render('gist_created', gist.created_on),
119 119 'created_on_raw': gist.created_on,
120 120 'type': _render('gist_type', gist.gist_type),
121 121 'access_id': _render('gist_access_id', gist.gist_access_id, gist.owner.full_contact),
122 122 'author': _render('gist_author', gist.owner.full_contact, gist.created_on, gist.gist_expires),
123 123 'author_raw': h.escape(gist.owner.full_contact),
124 124 'expires': _render('gist_expires', gist.gist_expires),
125 125 'description': _render('gist_description', gist.gist_description)
126 126 })
127 c.data = json.dumps(data)
127 c.data = ext_json.str_json(data)
128 128
129 129 return self._get_template_context(c)
130 130
131 131 @LoginRequired()
132 132 @NotAnonymous()
133 133 def gist_new(self):
134 134 c = self.load_default_context()
135 135 return self._get_template_context(c)
136 136
137 137 @LoginRequired()
138 138 @NotAnonymous()
139 139 @CSRFRequired()
140 140 def gist_create(self):
141 141 _ = self.request.translate
142 142 c = self.load_default_context()
143 143
144 144 data = dict(self.request.POST)
145 145 data['filename'] = data.get('filename') or Gist.DEFAULT_FILENAME
146 146
147 147 data['nodes'] = [{
148 148 'filename': data['filename'],
149 149 'content': data.get('content'),
150 150 'mimetype': data.get('mimetype') # None is autodetect
151 151 }]
152 152 gist_type = {
153 153 'public': Gist.GIST_PUBLIC,
154 154 'private': Gist.GIST_PRIVATE
155 155 }.get(data.get('gist_type')) or Gist.GIST_PRIVATE
156 156
157 157 data['gist_type'] = gist_type
158 158
159 159 data['gist_acl_level'] = (
160 160 data.get('gist_acl_level') or Gist.ACL_LEVEL_PRIVATE)
161 161
162 162 schema = gist_schema.GistSchema().bind(
163 163 lifetime_options=[x[0] for x in c.lifetime_values])
164 164
165 165 try:
166 166
167 167 schema_data = schema.deserialize(data)
168 168 # convert to safer format with just KEYs so we sure no duplicates
169 169 schema_data['nodes'] = gist_schema.sequence_to_nodes(
170 170 schema_data['nodes'])
171 171
172 172 gist = GistModel().create(
173 173 gist_id=schema_data['gistid'], # custom access id not real ID
174 174 description=schema_data['description'],
175 175 owner=self._rhodecode_user.user_id,
176 176 gist_mapping=schema_data['nodes'],
177 177 gist_type=schema_data['gist_type'],
178 178 lifetime=schema_data['lifetime'],
179 179 gist_acl_level=schema_data['gist_acl_level']
180 180 )
181 181 Session().commit()
182 182 new_gist_id = gist.gist_access_id
183 183 except validation_schema.Invalid as errors:
184 184 defaults = data
185 185 errors = errors.asdict()
186 186
187 187 if 'nodes.0.content' in errors:
188 188 errors['content'] = errors['nodes.0.content']
189 189 del errors['nodes.0.content']
190 190 if 'nodes.0.filename' in errors:
191 191 errors['filename'] = errors['nodes.0.filename']
192 192 del errors['nodes.0.filename']
193 193
194 194 data = render('rhodecode:templates/admin/gists/gist_new.mako',
195 195 self._get_template_context(c), self.request)
196 196 html = formencode.htmlfill.render(
197 197 data,
198 198 defaults=defaults,
199 199 errors=errors,
200 200 prefix_error=False,
201 201 encoding="UTF-8",
202 202 force_defaults=False
203 203 )
204 204 return Response(html)
205 205
206 206 except Exception:
207 207 log.exception("Exception while trying to create a gist")
208 208 h.flash(_('Error occurred during gist creation'), category='error')
209 209 raise HTTPFound(h.route_url('gists_new'))
210 210 raise HTTPFound(h.route_url('gist_show', gist_id=new_gist_id))
211 211
212 212 @LoginRequired()
213 213 @NotAnonymous()
214 214 @CSRFRequired()
215 215 def gist_delete(self):
216 216 _ = self.request.translate
217 217 gist_id = self.request.matchdict['gist_id']
218 218
219 219 c = self.load_default_context()
220 220 c.gist = Gist.get_or_404(gist_id)
221 221
222 222 owner = c.gist.gist_owner == self._rhodecode_user.user_id
223 223 if not (h.HasPermissionAny('hg.admin')() or owner):
224 224 log.warning('Deletion of Gist was forbidden '
225 225 'by unauthorized user: `%s`', self._rhodecode_user)
226 226 raise HTTPNotFound()
227 227
228 228 GistModel().delete(c.gist)
229 229 Session().commit()
230 230 h.flash(_('Deleted gist %s') % c.gist.gist_access_id, category='success')
231 231
232 232 raise HTTPFound(h.route_url('gists_show'))
233 233
234 234 def _get_gist(self, gist_id):
235 235
236 236 gist = Gist.get_or_404(gist_id)
237 237
238 238 # Check if this gist is expired
239 239 if gist.gist_expires != -1:
240 240 if time.time() > gist.gist_expires:
241 241 log.error(
242 242 'Gist expired at %s', time_to_datetime(gist.gist_expires))
243 243 raise HTTPNotFound()
244 244
245 245 # check if this gist requires a login
246 246 is_default_user = self._rhodecode_user.username == User.DEFAULT_USER
247 247 if gist.acl_level == Gist.ACL_LEVEL_PRIVATE and is_default_user:
248 248 log.error("Anonymous user %s tried to access protected gist `%s`",
249 249 self._rhodecode_user, gist_id)
250 250 raise HTTPNotFound()
251 251 return gist
252 252
253 253 @LoginRequired()
254 254 def gist_show(self):
255 255 gist_id = self.request.matchdict['gist_id']
256 256
257 257 # TODO(marcink): expose those via matching dict
258 258 revision = self.request.matchdict.get('revision', 'tip')
259 259 f_path = self.request.matchdict.get('f_path', None)
260 260 return_format = self.request.matchdict.get('format')
261 261
262 262 c = self.load_default_context()
263 263 c.gist = self._get_gist(gist_id)
264 264 c.render = not self.request.GET.get('no-render', False)
265 265
266 266 try:
267 267 c.file_last_commit, c.files = GistModel().get_gist_files(
268 268 gist_id, revision=revision)
269 269 except VCSError:
270 270 log.exception("Exception in gist show")
271 271 raise HTTPNotFound()
272 272
273 273 if return_format == 'raw':
274 274 content = '\n\n'.join([f.content for f in c.files
275 275 if (f_path is None or f.path == f_path)])
276 276 response = Response(content)
277 277 response.content_type = 'text/plain'
278 278 return response
279 279 elif return_format:
280 280 raise HTTPBadRequest()
281 281
282 282 return self._get_template_context(c)
283 283
284 284 @LoginRequired()
285 285 @NotAnonymous()
286 286 def gist_edit(self):
287 287 _ = self.request.translate
288 288 gist_id = self.request.matchdict['gist_id']
289 289 c = self.load_default_context()
290 290 c.gist = self._get_gist(gist_id)
291 291
292 292 owner = c.gist.gist_owner == self._rhodecode_user.user_id
293 293 if not (h.HasPermissionAny('hg.admin')() or owner):
294 294 raise HTTPNotFound()
295 295
296 296 try:
297 297 c.file_last_commit, c.files = GistModel().get_gist_files(gist_id)
298 298 except VCSError:
299 299 log.exception("Exception in gist edit")
300 300 raise HTTPNotFound()
301 301
302 302 if c.gist.gist_expires == -1:
303 303 expiry = _('never')
304 304 else:
305 305 # this cannot use timeago, since it's used in select2 as a value
306 306 expiry = h.age(h.time_to_datetime(c.gist.gist_expires))
307 307
308 308 c.lifetime_values.append(
309 309 (0, _('%(expiry)s - current value') % {'expiry': _(expiry)})
310 310 )
311 311
312 312 return self._get_template_context(c)
313 313
314 314 @LoginRequired()
315 315 @NotAnonymous()
316 316 @CSRFRequired()
317 317 def gist_update(self):
318 318 _ = self.request.translate
319 319 gist_id = self.request.matchdict['gist_id']
320 320 c = self.load_default_context()
321 321 c.gist = self._get_gist(gist_id)
322 322
323 323 owner = c.gist.gist_owner == self._rhodecode_user.user_id
324 324 if not (h.HasPermissionAny('hg.admin')() or owner):
325 325 raise HTTPNotFound()
326 326
327 327 data = peppercorn.parse(self.request.POST.items())
328 328
329 329 schema = gist_schema.GistSchema()
330 330 schema = schema.bind(
331 331 # '0' is special value to leave lifetime untouched
332 332 lifetime_options=[x[0] for x in c.lifetime_values] + [0],
333 333 )
334 334
335 335 try:
336 336 schema_data = schema.deserialize(data)
337 337 # convert to safer format with just KEYs so we sure no duplicates
338 338 schema_data['nodes'] = gist_schema.sequence_to_nodes(
339 339 schema_data['nodes'])
340 340
341 341 GistModel().update(
342 342 gist=c.gist,
343 343 description=schema_data['description'],
344 344 owner=c.gist.owner,
345 345 gist_mapping=schema_data['nodes'],
346 346 lifetime=schema_data['lifetime'],
347 347 gist_acl_level=schema_data['gist_acl_level']
348 348 )
349 349
350 350 Session().commit()
351 351 h.flash(_('Successfully updated gist content'), category='success')
352 352 except NodeNotChangedError:
353 353 # raised if nothing was changed in repo itself. We anyway then
354 354 # store only DB stuff for gist
355 355 Session().commit()
356 356 h.flash(_('Successfully updated gist data'), category='success')
357 357 except validation_schema.Invalid as errors:
358 358 errors = h.escape(errors.asdict())
359 359 h.flash(_('Error occurred during update of gist {}: {}').format(
360 360 gist_id, errors), category='error')
361 361 except Exception:
362 362 log.exception("Exception in gist edit")
363 363 h.flash(_('Error occurred during update of gist %s') % gist_id,
364 364 category='error')
365 365
366 366 raise HTTPFound(h.route_url('gist_show', gist_id=gist_id))
367 367
368 368 @LoginRequired()
369 369 @NotAnonymous()
370 370 def gist_edit_check_revision(self):
371 371 _ = self.request.translate
372 372 gist_id = self.request.matchdict['gist_id']
373 373 c = self.load_default_context()
374 374 c.gist = self._get_gist(gist_id)
375 375
376 376 last_rev = c.gist.scm_instance().get_commit()
377 377 success = True
378 378 revision = self.request.GET.get('revision')
379 379
380 380 if revision != last_rev.raw_id:
381 381 log.error('Last revision %s is different then submitted %s',
382 382 revision, last_rev)
383 383 # our gist has newer version than we
384 384 success = False
385 385
386 386 return {'success': success}
@@ -1,783 +1,783 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import datetime
23 23 import string
24 24
25 25 import formencode
26 26 import formencode.htmlfill
27 27 import peppercorn
28 28 from pyramid.httpexceptions import HTTPFound, HTTPNotFound
29 29
30 30 from rhodecode.apps._base import BaseAppView, DataGridAppView
31 31 from rhodecode import forms
32 32 from rhodecode.lib import helpers as h
33 33 from rhodecode.lib import audit_logger
34 from rhodecode.lib.ext_json import json
34 from rhodecode.lib import ext_json
35 35 from rhodecode.lib.auth import (
36 36 LoginRequired, NotAnonymous, CSRFRequired,
37 37 HasRepoPermissionAny, HasRepoGroupPermissionAny, AuthUser)
38 38 from rhodecode.lib.channelstream import (
39 39 channelstream_request, ChannelstreamException)
40 40 from rhodecode.lib.utils2 import safe_int, md5, str2bool
41 41 from rhodecode.model.auth_token import AuthTokenModel
42 42 from rhodecode.model.comment import CommentsModel
43 43 from rhodecode.model.db import (
44 44 IntegrityError, or_, in_filter_generator,
45 45 Repository, UserEmailMap, UserApiKeys, UserFollowing,
46 46 PullRequest, UserBookmark, RepoGroup, ChangesetStatus)
47 47 from rhodecode.model.meta import Session
48 48 from rhodecode.model.pull_request import PullRequestModel
49 49 from rhodecode.model.user import UserModel
50 50 from rhodecode.model.user_group import UserGroupModel
51 51 from rhodecode.model.validation_schema.schemas import user_schema
52 52
53 53 log = logging.getLogger(__name__)
54 54
55 55
56 56 class MyAccountView(BaseAppView, DataGridAppView):
57 57 ALLOW_SCOPED_TOKENS = False
58 58 """
59 59 This view has alternative version inside EE, if modified please take a look
60 60 in there as well.
61 61 """
62 62
63 63 def load_default_context(self):
64 64 c = self._get_local_tmpl_context()
65 65 c.user = c.auth_user.get_instance()
66 66 c.allow_scoped_tokens = self.ALLOW_SCOPED_TOKENS
67 67 return c
68 68
69 69 @LoginRequired()
70 70 @NotAnonymous()
71 71 def my_account_profile(self):
72 72 c = self.load_default_context()
73 73 c.active = 'profile'
74 74 c.extern_type = c.user.extern_type
75 75 return self._get_template_context(c)
76 76
77 77 @LoginRequired()
78 78 @NotAnonymous()
79 79 def my_account_edit(self):
80 80 c = self.load_default_context()
81 81 c.active = 'profile_edit'
82 82 c.extern_type = c.user.extern_type
83 83 c.extern_name = c.user.extern_name
84 84
85 85 schema = user_schema.UserProfileSchema().bind(
86 86 username=c.user.username, user_emails=c.user.emails)
87 87 appstruct = {
88 88 'username': c.user.username,
89 89 'email': c.user.email,
90 90 'firstname': c.user.firstname,
91 91 'lastname': c.user.lastname,
92 92 'description': c.user.description,
93 93 }
94 94 c.form = forms.RcForm(
95 95 schema, appstruct=appstruct,
96 96 action=h.route_path('my_account_update'),
97 97 buttons=(forms.buttons.save, forms.buttons.reset))
98 98
99 99 return self._get_template_context(c)
100 100
101 101 @LoginRequired()
102 102 @NotAnonymous()
103 103 @CSRFRequired()
104 104 def my_account_update(self):
105 105 _ = self.request.translate
106 106 c = self.load_default_context()
107 107 c.active = 'profile_edit'
108 108 c.perm_user = c.auth_user
109 109 c.extern_type = c.user.extern_type
110 110 c.extern_name = c.user.extern_name
111 111
112 112 schema = user_schema.UserProfileSchema().bind(
113 113 username=c.user.username, user_emails=c.user.emails)
114 114 form = forms.RcForm(
115 115 schema, buttons=(forms.buttons.save, forms.buttons.reset))
116 116
117 117 controls = self.request.POST.items()
118 118 try:
119 119 valid_data = form.validate(controls)
120 120 skip_attrs = ['admin', 'active', 'extern_type', 'extern_name',
121 121 'new_password', 'password_confirmation']
122 122 if c.extern_type != "rhodecode":
123 123 # forbid updating username for external accounts
124 124 skip_attrs.append('username')
125 125 old_email = c.user.email
126 126 UserModel().update_user(
127 127 self._rhodecode_user.user_id, skip_attrs=skip_attrs,
128 128 **valid_data)
129 129 if old_email != valid_data['email']:
130 130 old = UserEmailMap.query() \
131 131 .filter(UserEmailMap.user == c.user)\
132 132 .filter(UserEmailMap.email == valid_data['email'])\
133 133 .first()
134 134 old.email = old_email
135 135 h.flash(_('Your account was updated successfully'), category='success')
136 136 Session().commit()
137 137 except forms.ValidationFailure as e:
138 138 c.form = e
139 139 return self._get_template_context(c)
140 140 except Exception:
141 141 log.exception("Exception updating user")
142 142 h.flash(_('Error occurred during update of user'),
143 143 category='error')
144 144 raise HTTPFound(h.route_path('my_account_profile'))
145 145
146 146 @LoginRequired()
147 147 @NotAnonymous()
148 148 def my_account_password(self):
149 149 c = self.load_default_context()
150 150 c.active = 'password'
151 151 c.extern_type = c.user.extern_type
152 152
153 153 schema = user_schema.ChangePasswordSchema().bind(
154 154 username=c.user.username)
155 155
156 156 form = forms.Form(
157 157 schema,
158 158 action=h.route_path('my_account_password_update'),
159 159 buttons=(forms.buttons.save, forms.buttons.reset))
160 160
161 161 c.form = form
162 162 return self._get_template_context(c)
163 163
164 164 @LoginRequired()
165 165 @NotAnonymous()
166 166 @CSRFRequired()
167 167 def my_account_password_update(self):
168 168 _ = self.request.translate
169 169 c = self.load_default_context()
170 170 c.active = 'password'
171 171 c.extern_type = c.user.extern_type
172 172
173 173 schema = user_schema.ChangePasswordSchema().bind(
174 174 username=c.user.username)
175 175
176 176 form = forms.Form(
177 177 schema, buttons=(forms.buttons.save, forms.buttons.reset))
178 178
179 179 if c.extern_type != 'rhodecode':
180 180 raise HTTPFound(self.request.route_path('my_account_password'))
181 181
182 182 controls = self.request.POST.items()
183 183 try:
184 184 valid_data = form.validate(controls)
185 185 UserModel().update_user(c.user.user_id, **valid_data)
186 186 c.user.update_userdata(force_password_change=False)
187 187 Session().commit()
188 188 except forms.ValidationFailure as e:
189 189 c.form = e
190 190 return self._get_template_context(c)
191 191
192 192 except Exception:
193 193 log.exception("Exception updating password")
194 194 h.flash(_('Error occurred during update of user password'),
195 195 category='error')
196 196 else:
197 197 instance = c.auth_user.get_instance()
198 198 self.session.setdefault('rhodecode_user', {}).update(
199 199 {'password': md5(instance.password)})
200 200 self.session.save()
201 201 h.flash(_("Successfully updated password"), category='success')
202 202
203 203 raise HTTPFound(self.request.route_path('my_account_password'))
204 204
205 205 @LoginRequired()
206 206 @NotAnonymous()
207 207 def my_account_auth_tokens(self):
208 208 _ = self.request.translate
209 209
210 210 c = self.load_default_context()
211 211 c.active = 'auth_tokens'
212 212 c.lifetime_values = AuthTokenModel.get_lifetime_values(translator=_)
213 213 c.role_values = [
214 214 (x, AuthTokenModel.cls._get_role_name(x))
215 215 for x in AuthTokenModel.cls.ROLES]
216 216 c.role_options = [(c.role_values, _("Role"))]
217 217 c.user_auth_tokens = AuthTokenModel().get_auth_tokens(
218 218 c.user.user_id, show_expired=True)
219 219 c.role_vcs = AuthTokenModel.cls.ROLE_VCS
220 220 return self._get_template_context(c)
221 221
222 222 @LoginRequired()
223 223 @NotAnonymous()
224 224 @CSRFRequired()
225 225 def my_account_auth_tokens_view(self):
226 226 _ = self.request.translate
227 227 c = self.load_default_context()
228 228
229 229 auth_token_id = self.request.POST.get('auth_token_id')
230 230
231 231 if auth_token_id:
232 232 token = UserApiKeys.get_or_404(auth_token_id)
233 233 if token.user.user_id != c.user.user_id:
234 234 raise HTTPNotFound()
235 235
236 236 return {
237 237 'auth_token': token.api_key
238 238 }
239 239
240 240 def maybe_attach_token_scope(self, token):
241 241 # implemented in EE edition
242 242 pass
243 243
244 244 @LoginRequired()
245 245 @NotAnonymous()
246 246 @CSRFRequired()
247 247 def my_account_auth_tokens_add(self):
248 248 _ = self.request.translate
249 249 c = self.load_default_context()
250 250
251 251 lifetime = safe_int(self.request.POST.get('lifetime'), -1)
252 252 description = self.request.POST.get('description')
253 253 role = self.request.POST.get('role')
254 254
255 255 token = UserModel().add_auth_token(
256 256 user=c.user.user_id,
257 257 lifetime_minutes=lifetime, role=role, description=description,
258 258 scope_callback=self.maybe_attach_token_scope)
259 259 token_data = token.get_api_data()
260 260
261 261 audit_logger.store_web(
262 262 'user.edit.token.add', action_data={
263 263 'data': {'token': token_data, 'user': 'self'}},
264 264 user=self._rhodecode_user, )
265 265 Session().commit()
266 266
267 267 h.flash(_("Auth token successfully created"), category='success')
268 268 return HTTPFound(h.route_path('my_account_auth_tokens'))
269 269
270 270 @LoginRequired()
271 271 @NotAnonymous()
272 272 @CSRFRequired()
273 273 def my_account_auth_tokens_delete(self):
274 274 _ = self.request.translate
275 275 c = self.load_default_context()
276 276
277 277 del_auth_token = self.request.POST.get('del_auth_token')
278 278
279 279 if del_auth_token:
280 280 token = UserApiKeys.get_or_404(del_auth_token)
281 281 token_data = token.get_api_data()
282 282
283 283 AuthTokenModel().delete(del_auth_token, c.user.user_id)
284 284 audit_logger.store_web(
285 285 'user.edit.token.delete', action_data={
286 286 'data': {'token': token_data, 'user': 'self'}},
287 287 user=self._rhodecode_user,)
288 288 Session().commit()
289 289 h.flash(_("Auth token successfully deleted"), category='success')
290 290
291 291 return HTTPFound(h.route_path('my_account_auth_tokens'))
292 292
293 293 @LoginRequired()
294 294 @NotAnonymous()
295 295 def my_account_emails(self):
296 296 _ = self.request.translate
297 297
298 298 c = self.load_default_context()
299 299 c.active = 'emails'
300 300
301 301 c.user_email_map = UserEmailMap.query()\
302 302 .filter(UserEmailMap.user == c.user).all()
303 303
304 304 schema = user_schema.AddEmailSchema().bind(
305 305 username=c.user.username, user_emails=c.user.emails)
306 306
307 307 form = forms.RcForm(schema,
308 308 action=h.route_path('my_account_emails_add'),
309 309 buttons=(forms.buttons.save, forms.buttons.reset))
310 310
311 311 c.form = form
312 312 return self._get_template_context(c)
313 313
314 314 @LoginRequired()
315 315 @NotAnonymous()
316 316 @CSRFRequired()
317 317 def my_account_emails_add(self):
318 318 _ = self.request.translate
319 319 c = self.load_default_context()
320 320 c.active = 'emails'
321 321
322 322 schema = user_schema.AddEmailSchema().bind(
323 323 username=c.user.username, user_emails=c.user.emails)
324 324
325 325 form = forms.RcForm(
326 326 schema, action=h.route_path('my_account_emails_add'),
327 327 buttons=(forms.buttons.save, forms.buttons.reset))
328 328
329 329 controls = self.request.POST.items()
330 330 try:
331 331 valid_data = form.validate(controls)
332 332 UserModel().add_extra_email(c.user.user_id, valid_data['email'])
333 333 audit_logger.store_web(
334 334 'user.edit.email.add', action_data={
335 335 'data': {'email': valid_data['email'], 'user': 'self'}},
336 336 user=self._rhodecode_user,)
337 337 Session().commit()
338 338 except formencode.Invalid as error:
339 339 h.flash(h.escape(error.error_dict['email']), category='error')
340 340 except forms.ValidationFailure as e:
341 341 c.user_email_map = UserEmailMap.query() \
342 342 .filter(UserEmailMap.user == c.user).all()
343 343 c.form = e
344 344 return self._get_template_context(c)
345 345 except Exception:
346 346 log.exception("Exception adding email")
347 347 h.flash(_('Error occurred during adding email'),
348 348 category='error')
349 349 else:
350 350 h.flash(_("Successfully added email"), category='success')
351 351
352 352 raise HTTPFound(self.request.route_path('my_account_emails'))
353 353
354 354 @LoginRequired()
355 355 @NotAnonymous()
356 356 @CSRFRequired()
357 357 def my_account_emails_delete(self):
358 358 _ = self.request.translate
359 359 c = self.load_default_context()
360 360
361 361 del_email_id = self.request.POST.get('del_email_id')
362 362 if del_email_id:
363 363 email = UserEmailMap.get_or_404(del_email_id).email
364 364 UserModel().delete_extra_email(c.user.user_id, del_email_id)
365 365 audit_logger.store_web(
366 366 'user.edit.email.delete', action_data={
367 367 'data': {'email': email, 'user': 'self'}},
368 368 user=self._rhodecode_user,)
369 369 Session().commit()
370 370 h.flash(_("Email successfully deleted"),
371 371 category='success')
372 372 return HTTPFound(h.route_path('my_account_emails'))
373 373
374 374 @LoginRequired()
375 375 @NotAnonymous()
376 376 @CSRFRequired()
377 377 def my_account_notifications_test_channelstream(self):
378 378 message = 'Test message sent via Channelstream by user: {}, on {}'.format(
379 379 self._rhodecode_user.username, datetime.datetime.now())
380 380 payload = {
381 381 # 'channel': 'broadcast',
382 382 'type': 'message',
383 383 'timestamp': datetime.datetime.utcnow(),
384 384 'user': 'system',
385 385 'pm_users': [self._rhodecode_user.username],
386 386 'message': {
387 387 'message': message,
388 388 'level': 'info',
389 389 'topic': '/notifications'
390 390 }
391 391 }
392 392
393 393 registry = self.request.registry
394 394 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
395 395 channelstream_config = rhodecode_plugins.get('channelstream', {})
396 396
397 397 try:
398 398 channelstream_request(channelstream_config, [payload], '/message')
399 399 except ChannelstreamException as e:
400 400 log.exception('Failed to send channelstream data')
401 401 return {"response": 'ERROR: {}'.format(e.__class__.__name__)}
402 402 return {"response": 'Channelstream data sent. '
403 403 'You should see a new live message now.'}
404 404
405 405 def _load_my_repos_data(self, watched=False):
406 406
407 407 allowed_ids = [-1] + self._rhodecode_user.repo_acl_ids_from_stack(AuthUser.repo_read_perms)
408 408
409 409 if watched:
410 410 # repos user watch
411 411 repo_list = Session().query(
412 412 Repository
413 413 ) \
414 414 .join(
415 415 (UserFollowing, UserFollowing.follows_repo_id == Repository.repo_id)
416 416 ) \
417 417 .filter(
418 418 UserFollowing.user_id == self._rhodecode_user.user_id
419 419 ) \
420 420 .filter(or_(
421 421 # generate multiple IN to fix limitation problems
422 422 *in_filter_generator(Repository.repo_id, allowed_ids))
423 423 ) \
424 424 .order_by(Repository.repo_name) \
425 425 .all()
426 426
427 427 else:
428 428 # repos user is owner of
429 429 repo_list = Session().query(
430 430 Repository
431 431 ) \
432 432 .filter(
433 433 Repository.user_id == self._rhodecode_user.user_id
434 434 ) \
435 435 .filter(or_(
436 436 # generate multiple IN to fix limitation problems
437 437 *in_filter_generator(Repository.repo_id, allowed_ids))
438 438 ) \
439 439 .order_by(Repository.repo_name) \
440 440 .all()
441 441
442 442 _render = self.request.get_partial_renderer(
443 443 'rhodecode:templates/data_table/_dt_elements.mako')
444 444
445 445 def repo_lnk(name, rtype, rstate, private, archived, fork_of):
446 446 return _render('repo_name', name, rtype, rstate, private, archived, fork_of,
447 447 short_name=False, admin=False)
448 448
449 449 repos_data = []
450 450 for repo in repo_list:
451 451 row = {
452 452 "name": repo_lnk(repo.repo_name, repo.repo_type, repo.repo_state,
453 453 repo.private, repo.archived, repo.fork),
454 454 "name_raw": repo.repo_name.lower(),
455 455 }
456 456
457 457 repos_data.append(row)
458 458
459 459 # json used to render the grid
460 return json.dumps(repos_data)
460 return ext_json.str_json(repos_data)
461 461
462 462 @LoginRequired()
463 463 @NotAnonymous()
464 464 def my_account_repos(self):
465 465 c = self.load_default_context()
466 466 c.active = 'repos'
467 467
468 468 # json used to render the grid
469 469 c.data = self._load_my_repos_data()
470 470 return self._get_template_context(c)
471 471
472 472 @LoginRequired()
473 473 @NotAnonymous()
474 474 def my_account_watched(self):
475 475 c = self.load_default_context()
476 476 c.active = 'watched'
477 477
478 478 # json used to render the grid
479 479 c.data = self._load_my_repos_data(watched=True)
480 480 return self._get_template_context(c)
481 481
482 482 @LoginRequired()
483 483 @NotAnonymous()
484 484 def my_account_bookmarks(self):
485 485 c = self.load_default_context()
486 486 c.active = 'bookmarks'
487 487 c.bookmark_items = UserBookmark.get_bookmarks_for_user(
488 488 self._rhodecode_db_user.user_id, cache=False)
489 489 return self._get_template_context(c)
490 490
491 491 def _process_bookmark_entry(self, entry, user_id):
492 492 position = safe_int(entry.get('position'))
493 493 cur_position = safe_int(entry.get('cur_position'))
494 494 if position is None:
495 495 return
496 496
497 497 # check if this is an existing entry
498 498 is_new = False
499 499 db_entry = UserBookmark().get_by_position_for_user(cur_position, user_id)
500 500
501 501 if db_entry and str2bool(entry.get('remove')):
502 502 log.debug('Marked bookmark %s for deletion', db_entry)
503 503 Session().delete(db_entry)
504 504 return
505 505
506 506 if not db_entry:
507 507 # new
508 508 db_entry = UserBookmark()
509 509 is_new = True
510 510
511 511 should_save = False
512 512 default_redirect_url = ''
513 513
514 514 # save repo
515 515 if entry.get('bookmark_repo') and safe_int(entry.get('bookmark_repo')):
516 516 repo = Repository.get(entry['bookmark_repo'])
517 517 perm_check = HasRepoPermissionAny(
518 518 'repository.read', 'repository.write', 'repository.admin')
519 519 if repo and perm_check(repo_name=repo.repo_name):
520 520 db_entry.repository = repo
521 521 should_save = True
522 522 default_redirect_url = '${repo_url}'
523 523 # save repo group
524 524 elif entry.get('bookmark_repo_group') and safe_int(entry.get('bookmark_repo_group')):
525 525 repo_group = RepoGroup.get(entry['bookmark_repo_group'])
526 526 perm_check = HasRepoGroupPermissionAny(
527 527 'group.read', 'group.write', 'group.admin')
528 528
529 529 if repo_group and perm_check(group_name=repo_group.group_name):
530 530 db_entry.repository_group = repo_group
531 531 should_save = True
532 532 default_redirect_url = '${repo_group_url}'
533 533 # save generic info
534 534 elif entry.get('title') and entry.get('redirect_url'):
535 535 should_save = True
536 536
537 537 if should_save:
538 538 # mark user and position
539 539 db_entry.user_id = user_id
540 540 db_entry.position = position
541 541 db_entry.title = entry.get('title')
542 542 db_entry.redirect_url = entry.get('redirect_url') or default_redirect_url
543 543 log.debug('Saving bookmark %s, new:%s', db_entry, is_new)
544 544
545 545 Session().add(db_entry)
546 546
547 547 @LoginRequired()
548 548 @NotAnonymous()
549 549 @CSRFRequired()
550 550 def my_account_bookmarks_update(self):
551 551 _ = self.request.translate
552 552 c = self.load_default_context()
553 553 c.active = 'bookmarks'
554 554
555 555 controls = peppercorn.parse(self.request.POST.items())
556 556 user_id = c.user.user_id
557 557
558 558 # validate positions
559 559 positions = {}
560 560 for entry in controls.get('bookmarks', []):
561 561 position = safe_int(entry['position'])
562 562 if position is None:
563 563 continue
564 564
565 565 if position in positions:
566 566 h.flash(_("Position {} is defined twice. "
567 567 "Please correct this error.").format(position), category='error')
568 568 return HTTPFound(h.route_path('my_account_bookmarks'))
569 569
570 570 entry['position'] = position
571 571 entry['cur_position'] = safe_int(entry.get('cur_position'))
572 572 positions[position] = entry
573 573
574 574 try:
575 575 for entry in positions.values():
576 576 self._process_bookmark_entry(entry, user_id)
577 577
578 578 Session().commit()
579 579 h.flash(_("Update Bookmarks"), category='success')
580 580 except IntegrityError:
581 581 h.flash(_("Failed to update bookmarks. "
582 582 "Make sure an unique position is used."), category='error')
583 583
584 584 return HTTPFound(h.route_path('my_account_bookmarks'))
585 585
586 586 @LoginRequired()
587 587 @NotAnonymous()
588 588 def my_account_goto_bookmark(self):
589 589
590 590 bookmark_id = self.request.matchdict['bookmark_id']
591 591 user_bookmark = UserBookmark().query()\
592 592 .filter(UserBookmark.user_id == self.request.user.user_id) \
593 593 .filter(UserBookmark.position == bookmark_id).scalar()
594 594
595 595 redirect_url = h.route_path('my_account_bookmarks')
596 596 if not user_bookmark:
597 597 raise HTTPFound(redirect_url)
598 598
599 599 # repository set
600 600 if user_bookmark.repository:
601 601 repo_name = user_bookmark.repository.repo_name
602 602 base_redirect_url = h.route_path(
603 603 'repo_summary', repo_name=repo_name)
604 604 if user_bookmark.redirect_url and \
605 605 '${repo_url}' in user_bookmark.redirect_url:
606 606 redirect_url = string.Template(user_bookmark.redirect_url)\
607 607 .safe_substitute({'repo_url': base_redirect_url})
608 608 else:
609 609 redirect_url = base_redirect_url
610 610 # repository group set
611 611 elif user_bookmark.repository_group:
612 612 repo_group_name = user_bookmark.repository_group.group_name
613 613 base_redirect_url = h.route_path(
614 614 'repo_group_home', repo_group_name=repo_group_name)
615 615 if user_bookmark.redirect_url and \
616 616 '${repo_group_url}' in user_bookmark.redirect_url:
617 617 redirect_url = string.Template(user_bookmark.redirect_url)\
618 618 .safe_substitute({'repo_group_url': base_redirect_url})
619 619 else:
620 620 redirect_url = base_redirect_url
621 621 # custom URL set
622 622 elif user_bookmark.redirect_url:
623 623 server_url = h.route_url('home').rstrip('/')
624 624 redirect_url = string.Template(user_bookmark.redirect_url) \
625 625 .safe_substitute({'server_url': server_url})
626 626
627 627 log.debug('Redirecting bookmark %s to %s', user_bookmark, redirect_url)
628 628 raise HTTPFound(redirect_url)
629 629
630 630 @LoginRequired()
631 631 @NotAnonymous()
632 632 def my_account_perms(self):
633 633 c = self.load_default_context()
634 634 c.active = 'perms'
635 635
636 636 c.perm_user = c.auth_user
637 637 return self._get_template_context(c)
638 638
639 639 @LoginRequired()
640 640 @NotAnonymous()
641 641 def my_notifications(self):
642 642 c = self.load_default_context()
643 643 c.active = 'notifications'
644 644
645 645 return self._get_template_context(c)
646 646
647 647 @LoginRequired()
648 648 @NotAnonymous()
649 649 @CSRFRequired()
650 650 def my_notifications_toggle_visibility(self):
651 651 user = self._rhodecode_db_user
652 652 new_status = not user.user_data.get('notification_status', True)
653 653 user.update_userdata(notification_status=new_status)
654 654 Session().commit()
655 655 return user.user_data['notification_status']
656 656
657 657 def _get_pull_requests_list(self, statuses, filter_type=None):
658 658 draw, start, limit = self._extract_chunk(self.request)
659 659 search_q, order_by, order_dir = self._extract_ordering(self.request)
660 660
661 661 _render = self.request.get_partial_renderer(
662 662 'rhodecode:templates/data_table/_dt_elements.mako')
663 663
664 664 if filter_type == 'awaiting_my_review':
665 665 pull_requests = PullRequestModel().get_im_participating_in_for_review(
666 666 user_id=self._rhodecode_user.user_id,
667 667 statuses=statuses, query=search_q,
668 668 offset=start, length=limit, order_by=order_by,
669 669 order_dir=order_dir)
670 670
671 671 pull_requests_total_count = PullRequestModel().count_im_participating_in_for_review(
672 672 user_id=self._rhodecode_user.user_id, statuses=statuses, query=search_q)
673 673 else:
674 674 pull_requests = PullRequestModel().get_im_participating_in(
675 675 user_id=self._rhodecode_user.user_id,
676 676 statuses=statuses, query=search_q,
677 677 offset=start, length=limit, order_by=order_by,
678 678 order_dir=order_dir)
679 679
680 680 pull_requests_total_count = PullRequestModel().count_im_participating_in(
681 681 user_id=self._rhodecode_user.user_id, statuses=statuses, query=search_q)
682 682
683 683 data = []
684 684 comments_model = CommentsModel()
685 685 for pr in pull_requests:
686 686 repo_id = pr.target_repo_id
687 687 comments_count = comments_model.get_all_comments(
688 688 repo_id, pull_request=pr, include_drafts=False, count_only=True)
689 689 owned = pr.user_id == self._rhodecode_user.user_id
690 690
691 691 review_statuses = pr.reviewers_statuses(user=self._rhodecode_db_user)
692 692 my_review_status = ChangesetStatus.STATUS_NOT_REVIEWED
693 693 if review_statuses and review_statuses[4]:
694 694 _review_obj, _user, _reasons, _mandatory, statuses = review_statuses
695 695 my_review_status = statuses[0][1].status
696 696
697 697 data.append({
698 698 'target_repo': _render('pullrequest_target_repo',
699 699 pr.target_repo.repo_name),
700 700 'name': _render('pullrequest_name',
701 701 pr.pull_request_id, pr.pull_request_state,
702 702 pr.work_in_progress, pr.target_repo.repo_name,
703 703 short=True),
704 704 'name_raw': pr.pull_request_id,
705 705 'status': _render('pullrequest_status',
706 706 pr.calculated_review_status()),
707 707 'my_status': _render('pullrequest_status',
708 708 my_review_status),
709 709 'title': _render('pullrequest_title', pr.title, pr.description),
710 710 'description': h.escape(pr.description),
711 711 'updated_on': _render('pullrequest_updated_on',
712 712 h.datetime_to_time(pr.updated_on),
713 713 pr.versions_count),
714 714 'updated_on_raw': h.datetime_to_time(pr.updated_on),
715 715 'created_on': _render('pullrequest_updated_on',
716 716 h.datetime_to_time(pr.created_on)),
717 717 'created_on_raw': h.datetime_to_time(pr.created_on),
718 718 'state': pr.pull_request_state,
719 719 'author': _render('pullrequest_author',
720 720 pr.author.full_contact, ),
721 721 'author_raw': pr.author.full_name,
722 722 'comments': _render('pullrequest_comments', comments_count),
723 723 'comments_raw': comments_count,
724 724 'closed': pr.is_closed(),
725 725 'owned': owned
726 726 })
727 727
728 728 # json used to render the grid
729 729 data = ({
730 730 'draw': draw,
731 731 'data': data,
732 732 'recordsTotal': pull_requests_total_count,
733 733 'recordsFiltered': pull_requests_total_count,
734 734 })
735 735 return data
736 736
737 737 @LoginRequired()
738 738 @NotAnonymous()
739 739 def my_account_pullrequests(self):
740 740 c = self.load_default_context()
741 741 c.active = 'pullrequests'
742 742 req_get = self.request.GET
743 743
744 744 c.closed = str2bool(req_get.get('closed'))
745 745 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
746 746
747 747 c.selected_filter = 'all'
748 748 if c.closed:
749 749 c.selected_filter = 'all_closed'
750 750 if c.awaiting_my_review:
751 751 c.selected_filter = 'awaiting_my_review'
752 752
753 753 return self._get_template_context(c)
754 754
755 755 @LoginRequired()
756 756 @NotAnonymous()
757 757 def my_account_pullrequests_data(self):
758 758 self.load_default_context()
759 759 req_get = self.request.GET
760 760
761 761 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
762 762 closed = str2bool(req_get.get('closed'))
763 763
764 764 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
765 765 if closed:
766 766 statuses += [PullRequest.STATUS_CLOSED]
767 767
768 768 filter_type = \
769 769 'awaiting_my_review' if awaiting_my_review \
770 770 else None
771 771
772 772 data = self._get_pull_requests_list(statuses=statuses, filter_type=filter_type)
773 773 return data
774 774
775 775 @LoginRequired()
776 776 @NotAnonymous()
777 777 def my_account_user_group_membership(self):
778 778 c = self.load_default_context()
779 779 c.active = 'user_group_membership'
780 780 groups = [UserGroupModel.get_user_groups_as_dict(group.users_group)
781 781 for group in self._rhodecode_db_user.group_member]
782 c.user_groups = json.dumps(groups)
782 c.user_groups = ext_json.str_json(groups)
783 783 return self._get_template_context(c)
@@ -1,51 +1,50 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 import logging
21 21
22 22 from pyramid.httpexceptions import HTTPNotFound
23 23
24
25 24 from rhodecode.apps._base import BaseReferencesView
26 from rhodecode.lib.ext_json import json
25 from rhodecode.lib import ext_json
27 26 from rhodecode.lib import helpers as h
28 27 from rhodecode.lib.auth import (LoginRequired, HasRepoPermissionAnyDecorator)
29 28
30 29
31 30 log = logging.getLogger(__name__)
32 31
33 32
34 33 class RepoBookmarksView(BaseReferencesView):
35 34
36 35 @LoginRequired()
37 36 @HasRepoPermissionAnyDecorator(
38 37 'repository.read', 'repository.write', 'repository.admin')
39 38 def bookmarks(self):
40 39 c = self.load_default_context()
41 40
42 41 if not h.is_hg(self.db_repo):
43 42 raise HTTPNotFound()
44 43
45 44 ref_items = self.rhodecode_vcs_repo.bookmarks.items()
46 45 data = self.load_refs_context(
47 46 ref_items=ref_items, partials_template='bookmarks/bookmarks_data.mako')
48 47
49 48 c.has_references = bool(data)
50 c.data = json.dumps(data)
49 c.data = ext_json.str_json(data)
51 50 return self._get_template_context(c)
@@ -1,46 +1,46 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22
23 23
24 24 from rhodecode.apps._base import BaseReferencesView
25 from rhodecode.lib.ext_json import json
25 from rhodecode.lib import ext_json
26 26 from rhodecode.lib.auth import (LoginRequired, HasRepoPermissionAnyDecorator)
27 27
28 28
29 29 log = logging.getLogger(__name__)
30 30
31 31
32 32 class RepoBranchesView(BaseReferencesView):
33 33
34 34 @LoginRequired()
35 35 @HasRepoPermissionAnyDecorator(
36 36 'repository.read', 'repository.write', 'repository.admin')
37 37 def branches(self):
38 38 c = self.load_default_context()
39 39
40 40 ref_items = self.rhodecode_vcs_repo.branches_all.items()
41 41 data = self.load_refs_context(
42 42 ref_items=ref_items, partials_template='branches/branches_data.mako')
43 43
44 44 c.has_references = bool(data)
45 c.data = json.dumps(data)
45 c.data = ext_json.str_json(data)
46 46 return self._get_template_context(c)
@@ -1,355 +1,356 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
24 24 from pyramid.httpexceptions import HTTPNotFound, HTTPFound
25 25
26 26 from pyramid.renderers import render
27 27 from pyramid.response import Response
28 28
29 29 from rhodecode.apps._base import RepoAppView
30 30 import rhodecode.lib.helpers as h
31 from rhodecode.lib import ext_json
31 32 from rhodecode.lib.auth import (
32 33 LoginRequired, HasRepoPermissionAnyDecorator)
33 34
34 35 from rhodecode.lib.ext_json import json
35 36 from rhodecode.lib.graphmod import _colored, _dagwalker
36 37 from rhodecode.lib.helpers import RepoPage
37 38 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
38 39 from rhodecode.lib.vcs.exceptions import (
39 40 RepositoryError, CommitDoesNotExistError,
40 41 CommitError, NodeDoesNotExistError, EmptyRepositoryError)
41 42
42 43 log = logging.getLogger(__name__)
43 44
44 45 DEFAULT_CHANGELOG_SIZE = 20
45 46
46 47
47 48 class RepoChangelogView(RepoAppView):
48 49
49 50 def _get_commit_or_redirect(self, commit_id, redirect_after=True):
50 51 """
51 52 This is a safe way to get commit. If an error occurs it redirects to
52 53 tip with proper message
53 54
54 55 :param commit_id: id of commit to fetch
55 56 :param redirect_after: toggle redirection
56 57 """
57 58 _ = self.request.translate
58 59
59 60 try:
60 61 return self.rhodecode_vcs_repo.get_commit(commit_id)
61 62 except EmptyRepositoryError:
62 63 if not redirect_after:
63 64 return None
64 65
65 66 h.flash(h.literal(
66 67 _('There are no commits yet')), category='warning')
67 68 raise HTTPFound(
68 69 h.route_path('repo_summary', repo_name=self.db_repo_name))
69 70
70 71 except (CommitDoesNotExistError, LookupError):
71 72 msg = _('No such commit exists for this repository')
72 73 h.flash(msg, category='error')
73 74 raise HTTPNotFound()
74 75 except RepositoryError as e:
75 76 h.flash(h.escape(safe_str(e)), category='error')
76 77 raise HTTPNotFound()
77 78
78 79 def _graph(self, repo, commits, prev_data=None, next_data=None):
79 80 """
80 81 Generates a DAG graph for repo
81 82
82 83 :param repo: repo instance
83 84 :param commits: list of commits
84 85 """
85 86 if not commits:
86 87 return json.dumps([]), json.dumps([])
87 88
88 89 def serialize(commit, parents=True):
89 90 data = dict(
90 91 raw_id=commit.raw_id,
91 92 idx=commit.idx,
92 93 branch=None,
93 94 )
94 95 if parents:
95 96 data['parents'] = [
96 97 serialize(x, parents=False) for x in commit.parents]
97 98 return data
98 99
99 100 prev_data = prev_data or []
100 101 next_data = next_data or []
101 102
102 103 current = [serialize(x) for x in commits]
103 104 commits = prev_data + current + next_data
104 105
105 106 dag = _dagwalker(repo, commits)
106 107
107 108 data = [[commit_id, vtx, edges, branch]
108 109 for commit_id, vtx, edges, branch in _colored(dag)]
109 return json.dumps(data), json.dumps(current)
110 return ext_json.str_json(data), ext_json.str_json(current)
110 111
111 112 def _check_if_valid_branch(self, branch_name, repo_name, f_path):
112 113 if branch_name not in self.rhodecode_vcs_repo.branches_all:
113 114 h.flash(u'Branch {} is not found.'.format(h.escape(safe_unicode(branch_name))),
114 115 category='warning')
115 116 redirect_url = h.route_path(
116 117 'repo_commits_file', repo_name=repo_name,
117 118 commit_id=branch_name, f_path=f_path or '')
118 119 raise HTTPFound(redirect_url)
119 120
120 121 def _load_changelog_data(
121 122 self, c, collection, page, chunk_size, branch_name=None,
122 123 dynamic=False, f_path=None, commit_id=None):
123 124
124 125 def url_generator(page_num):
125 126 query_params = {
126 127 'page': page_num
127 128 }
128 129
129 130 if branch_name:
130 131 query_params.update({
131 132 'branch': branch_name
132 133 })
133 134
134 135 if f_path:
135 136 # changelog for file
136 137 return h.route_path(
137 138 'repo_commits_file',
138 139 repo_name=c.rhodecode_db_repo.repo_name,
139 140 commit_id=commit_id, f_path=f_path,
140 141 _query=query_params)
141 142 else:
142 143 return h.route_path(
143 144 'repo_commits',
144 145 repo_name=c.rhodecode_db_repo.repo_name, _query=query_params)
145 146
146 147 c.total_cs = len(collection)
147 148 c.showing_commits = min(chunk_size, c.total_cs)
148 149 c.pagination = RepoPage(collection, page=page, item_count=c.total_cs,
149 150 items_per_page=chunk_size, url_maker=url_generator)
150 151
151 152 c.next_page = c.pagination.next_page
152 153 c.prev_page = c.pagination.previous_page
153 154
154 155 if dynamic:
155 156 if self.request.GET.get('chunk') != 'next':
156 157 c.next_page = None
157 158 if self.request.GET.get('chunk') != 'prev':
158 159 c.prev_page = None
159 160
160 161 page_commit_ids = [x.raw_id for x in c.pagination]
161 162 c.comments = c.rhodecode_db_repo.get_comments(page_commit_ids)
162 163 c.statuses = c.rhodecode_db_repo.statuses(page_commit_ids)
163 164
164 165 def load_default_context(self):
165 166 c = self._get_local_tmpl_context(include_app_defaults=True)
166 167
167 168 c.rhodecode_repo = self.rhodecode_vcs_repo
168 169
169 170 return c
170 171
171 172 @LoginRequired()
172 173 @HasRepoPermissionAnyDecorator(
173 174 'repository.read', 'repository.write', 'repository.admin')
174 175 def repo_changelog(self):
175 176 c = self.load_default_context()
176 177
177 178 commit_id = self.request.matchdict.get('commit_id')
178 179 f_path = self._get_f_path(self.request.matchdict)
179 180 show_hidden = str2bool(self.request.GET.get('evolve'))
180 181
181 182 chunk_size = 20
182 183
183 184 c.branch_name = branch_name = self.request.GET.get('branch') or ''
184 185 c.book_name = book_name = self.request.GET.get('bookmark') or ''
185 186 c.f_path = f_path
186 187 c.commit_id = commit_id
187 188 c.show_hidden = show_hidden
188 189
189 190 hist_limit = safe_int(self.request.GET.get('limit')) or None
190 191
191 192 p = safe_int(self.request.GET.get('page', 1), 1)
192 193
193 194 c.selected_name = branch_name or book_name
194 195 if not commit_id and branch_name:
195 196 self._check_if_valid_branch(branch_name, self.db_repo_name, f_path)
196 197
197 198 c.changelog_for_path = f_path
198 199 pre_load = self.get_commit_preload_attrs()
199 200
200 201 partial_xhr = self.request.environ.get('HTTP_X_PARTIAL_XHR')
201 202
202 203 try:
203 204 if f_path:
204 205 log.debug('generating changelog for path %s', f_path)
205 206 # get the history for the file !
206 207 base_commit = self.rhodecode_vcs_repo.get_commit(commit_id)
207 208
208 209 try:
209 210 collection = base_commit.get_path_history(
210 211 f_path, limit=hist_limit, pre_load=pre_load)
211 212 if collection and partial_xhr:
212 213 # for ajax call we remove first one since we're looking
213 214 # at it right now in the context of a file commit
214 215 collection.pop(0)
215 216 except (NodeDoesNotExistError, CommitError):
216 217 # this node is not present at tip!
217 218 try:
218 219 commit = self._get_commit_or_redirect(commit_id)
219 220 collection = commit.get_path_history(f_path)
220 221 except RepositoryError as e:
221 222 h.flash(safe_str(e), category='warning')
222 223 redirect_url = h.route_path(
223 224 'repo_commits', repo_name=self.db_repo_name)
224 225 raise HTTPFound(redirect_url)
225 226 collection = list(reversed(collection))
226 227 else:
227 228 collection = self.rhodecode_vcs_repo.get_commits(
228 229 branch_name=branch_name, show_hidden=show_hidden,
229 230 pre_load=pre_load, translate_tags=False)
230 231
231 232 self._load_changelog_data(
232 233 c, collection, p, chunk_size, c.branch_name,
233 234 f_path=f_path, commit_id=commit_id)
234 235
235 236 except EmptyRepositoryError as e:
236 237 h.flash(h.escape(safe_str(e)), category='warning')
237 238 raise HTTPFound(
238 239 h.route_path('repo_summary', repo_name=self.db_repo_name))
239 240 except HTTPFound:
240 241 raise
241 242 except (RepositoryError, CommitDoesNotExistError, Exception) as e:
242 243 log.exception(safe_str(e))
243 244 h.flash(h.escape(safe_str(e)), category='error')
244 245
245 246 if commit_id:
246 247 # from single commit page, we redirect to main commits
247 248 raise HTTPFound(
248 249 h.route_path('repo_commits', repo_name=self.db_repo_name))
249 250 else:
250 251 # otherwise we redirect to summary
251 252 raise HTTPFound(
252 253 h.route_path('repo_summary', repo_name=self.db_repo_name))
253 254
254 255
255 256
256 257 if partial_xhr or self.request.environ.get('HTTP_X_PJAX'):
257 258 # case when loading dynamic file history in file view
258 259 # loading from ajax, we don't want the first result, it's popped
259 260 # in the code above
260 261 html = render(
261 262 'rhodecode:templates/commits/changelog_file_history.mako',
262 263 self._get_template_context(c), self.request)
263 264 return Response(html)
264 265
265 266 commit_ids = []
266 267 if not f_path:
267 268 # only load graph data when not in file history mode
268 269 commit_ids = c.pagination
269 270
270 271 c.graph_data, c.graph_commits = self._graph(
271 272 self.rhodecode_vcs_repo, commit_ids)
272 273
273 274 return self._get_template_context(c)
274 275
275 276 @LoginRequired()
276 277 @HasRepoPermissionAnyDecorator(
277 278 'repository.read', 'repository.write', 'repository.admin')
278 279 def repo_commits_elements(self):
279 280 c = self.load_default_context()
280 281 commit_id = self.request.matchdict.get('commit_id')
281 282 f_path = self._get_f_path(self.request.matchdict)
282 283 show_hidden = str2bool(self.request.GET.get('evolve'))
283 284
284 285 chunk_size = 20
285 286 hist_limit = safe_int(self.request.GET.get('limit')) or None
286 287
287 288 def wrap_for_error(err):
288 289 html = '<tr>' \
289 290 '<td colspan="9" class="alert alert-error">ERROR: {}</td>' \
290 291 '</tr>'.format(err)
291 292 return Response(html)
292 293
293 294 c.branch_name = branch_name = self.request.GET.get('branch') or ''
294 295 c.book_name = book_name = self.request.GET.get('bookmark') or ''
295 296 c.f_path = f_path
296 297 c.commit_id = commit_id
297 298 c.show_hidden = show_hidden
298 299
299 300 c.selected_name = branch_name or book_name
300 301 if branch_name and branch_name not in self.rhodecode_vcs_repo.branches_all:
301 302 return wrap_for_error(
302 303 safe_str('Branch: {} is not valid'.format(branch_name)))
303 304
304 305 pre_load = self.get_commit_preload_attrs()
305 306
306 307 if f_path:
307 308 try:
308 309 base_commit = self.rhodecode_vcs_repo.get_commit(commit_id)
309 310 except (RepositoryError, CommitDoesNotExistError, Exception) as e:
310 311 log.exception(safe_str(e))
311 312 raise HTTPFound(
312 313 h.route_path('repo_commits', repo_name=self.db_repo_name))
313 314
314 315 collection = base_commit.get_path_history(
315 316 f_path, limit=hist_limit, pre_load=pre_load)
316 317 collection = list(reversed(collection))
317 318 else:
318 319 collection = self.rhodecode_vcs_repo.get_commits(
319 320 branch_name=branch_name, show_hidden=show_hidden, pre_load=pre_load,
320 321 translate_tags=False)
321 322
322 323 p = safe_int(self.request.GET.get('page', 1), 1)
323 324 try:
324 325 self._load_changelog_data(
325 326 c, collection, p, chunk_size, dynamic=True,
326 327 f_path=f_path, commit_id=commit_id)
327 328 except EmptyRepositoryError as e:
328 329 return wrap_for_error(safe_str(e))
329 330 except (RepositoryError, CommitDoesNotExistError, Exception) as e:
330 331 log.exception('Failed to fetch commits')
331 332 return wrap_for_error(safe_str(e))
332 333
333 334 prev_data = None
334 335 next_data = None
335 336
336 337 try:
337 338 prev_graph = json.loads(self.request.POST.get('graph') or '{}')
338 339 except json.JSONDecodeError:
339 340 prev_graph = {}
340 341
341 342 if self.request.GET.get('chunk') == 'prev':
342 343 next_data = prev_graph
343 344 elif self.request.GET.get('chunk') == 'next':
344 345 prev_data = prev_graph
345 346
346 347 commit_ids = []
347 348 if not f_path:
348 349 # only load graph data when not in file history mode
349 350 commit_ids = c.pagination
350 351
351 352 c.graph_data, c.graph_commits = self._graph(
352 353 self.rhodecode_vcs_repo, commit_ids,
353 354 prev_data=prev_data, next_data=next_data)
354 355
355 356 return self._get_template_context(c)
@@ -1,819 +1,819 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 logging
22 22 import collections
23 23
24 24 from pyramid.httpexceptions import (
25 25 HTTPNotFound, HTTPBadRequest, HTTPFound, HTTPForbidden, HTTPConflict)
26 26 from pyramid.renderers import render
27 27 from pyramid.response import Response
28 28
29 29 from rhodecode.apps._base import RepoAppView
30 30 from rhodecode.apps.file_store import utils as store_utils
31 31 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException
32 32
33 33 from rhodecode.lib import diffs, codeblocks, channelstream
34 34 from rhodecode.lib.auth import (
35 35 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
36 from rhodecode.lib.ext_json import json
36 from rhodecode.lib import ext_json
37 37 from collections import OrderedDict
38 38 from rhodecode.lib.diffs import (
39 39 cache_diff, load_cached_diff, diff_cache_exist, get_diff_context,
40 40 get_diff_whitespace_flag)
41 41 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError, CommentVersionMismatch
42 42 import rhodecode.lib.helpers as h
43 43 from rhodecode.lib.utils2 import safe_unicode, str2bool, StrictAttributeDict, safe_str
44 44 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 RepositoryError, CommitDoesNotExistError)
47 47 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore, \
48 48 ChangesetCommentHistory
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 50 from rhodecode.model.comment import CommentsModel
51 51 from rhodecode.model.meta import Session
52 52 from rhodecode.model.settings import VcsSettingsModel
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 def _update_with_GET(params, request):
58 58 for k in ['diff1', 'diff2', 'diff']:
59 59 params[k] += request.GET.getall(k)
60 60
61 61
62 62 class RepoCommitsView(RepoAppView):
63 63 def load_default_context(self):
64 64 c = self._get_local_tmpl_context(include_app_defaults=True)
65 65 c.rhodecode_repo = self.rhodecode_vcs_repo
66 66
67 67 return c
68 68
69 69 def _is_diff_cache_enabled(self, target_repo):
70 70 caching_enabled = self._get_general_setting(
71 71 target_repo, 'rhodecode_diff_cache')
72 72 log.debug('Diff caching enabled: %s', caching_enabled)
73 73 return caching_enabled
74 74
75 75 def _commit(self, commit_id_range, method):
76 76 _ = self.request.translate
77 77 c = self.load_default_context()
78 78 c.fulldiff = self.request.GET.get('fulldiff')
79 79 redirect_to_combined = str2bool(self.request.GET.get('redirect_combined'))
80 80
81 81 # fetch global flags of ignore ws or context lines
82 82 diff_context = get_diff_context(self.request)
83 83 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
84 84
85 85 # diff_limit will cut off the whole diff if the limit is applied
86 86 # otherwise it will just hide the big files from the front-end
87 87 diff_limit = c.visual.cut_off_limit_diff
88 88 file_limit = c.visual.cut_off_limit_file
89 89
90 90 # get ranges of commit ids if preset
91 91 commit_range = commit_id_range.split('...')[:2]
92 92
93 93 try:
94 94 pre_load = ['affected_files', 'author', 'branch', 'date',
95 95 'message', 'parents']
96 96 if self.rhodecode_vcs_repo.alias == 'hg':
97 97 pre_load += ['hidden', 'obsolete', 'phase']
98 98
99 99 if len(commit_range) == 2:
100 100 commits = self.rhodecode_vcs_repo.get_commits(
101 101 start_id=commit_range[0], end_id=commit_range[1],
102 102 pre_load=pre_load, translate_tags=False)
103 103 commits = list(commits)
104 104 else:
105 105 commits = [self.rhodecode_vcs_repo.get_commit(
106 106 commit_id=commit_id_range, pre_load=pre_load)]
107 107
108 108 c.commit_ranges = commits
109 109 if not c.commit_ranges:
110 110 raise RepositoryError('The commit range returned an empty result')
111 111 except CommitDoesNotExistError as e:
112 112 msg = _('No such commit exists. Org exception: `{}`').format(safe_str(e))
113 113 h.flash(msg, category='error')
114 114 raise HTTPNotFound()
115 115 except Exception:
116 116 log.exception("General failure")
117 117 raise HTTPNotFound()
118 118 single_commit = len(c.commit_ranges) == 1
119 119
120 120 if redirect_to_combined and not single_commit:
121 121 source_ref = getattr(c.commit_ranges[0].parents[0]
122 122 if c.commit_ranges[0].parents else h.EmptyCommit(), 'raw_id')
123 123 target_ref = c.commit_ranges[-1].raw_id
124 124 next_url = h.route_path(
125 125 'repo_compare',
126 126 repo_name=c.repo_name,
127 127 source_ref_type='rev',
128 128 source_ref=source_ref,
129 129 target_ref_type='rev',
130 130 target_ref=target_ref)
131 131 raise HTTPFound(next_url)
132 132
133 133 c.changes = OrderedDict()
134 134 c.lines_added = 0
135 135 c.lines_deleted = 0
136 136
137 137 # auto collapse if we have more than limit
138 138 collapse_limit = diffs.DiffProcessor._collapse_commits_over
139 139 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
140 140
141 141 c.commit_statuses = ChangesetStatus.STATUSES
142 142 c.inline_comments = []
143 143 c.files = []
144 144
145 145 c.comments = []
146 146 c.unresolved_comments = []
147 147 c.resolved_comments = []
148 148
149 149 # Single commit
150 150 if single_commit:
151 151 commit = c.commit_ranges[0]
152 152 c.comments = CommentsModel().get_comments(
153 153 self.db_repo.repo_id,
154 154 revision=commit.raw_id)
155 155
156 156 # comments from PR
157 157 statuses = ChangesetStatusModel().get_statuses(
158 158 self.db_repo.repo_id, commit.raw_id,
159 159 with_revisions=True)
160 160
161 161 prs = set()
162 162 reviewers = list()
163 163 reviewers_duplicates = set() # to not have duplicates from multiple votes
164 164 for c_status in statuses:
165 165
166 166 # extract associated pull-requests from votes
167 167 if c_status.pull_request:
168 168 prs.add(c_status.pull_request)
169 169
170 170 # extract reviewers
171 171 _user_id = c_status.author.user_id
172 172 if _user_id not in reviewers_duplicates:
173 173 reviewers.append(
174 174 StrictAttributeDict({
175 175 'user': c_status.author,
176 176
177 177 # fake attributed for commit, page that we don't have
178 178 # but we share the display with PR page
179 179 'mandatory': False,
180 180 'reasons': [],
181 181 'rule_user_group_data': lambda: None
182 182 })
183 183 )
184 184 reviewers_duplicates.add(_user_id)
185 185
186 186 c.reviewers_count = len(reviewers)
187 187 c.observers_count = 0
188 188
189 189 # from associated statuses, check the pull requests, and
190 190 # show comments from them
191 191 for pr in prs:
192 192 c.comments.extend(pr.comments)
193 193
194 194 c.unresolved_comments = CommentsModel()\
195 195 .get_commit_unresolved_todos(commit.raw_id)
196 196 c.resolved_comments = CommentsModel()\
197 197 .get_commit_resolved_todos(commit.raw_id)
198 198
199 199 c.inline_comments_flat = CommentsModel()\
200 200 .get_commit_inline_comments(commit.raw_id)
201 201
202 202 review_statuses = ChangesetStatusModel().aggregate_votes_by_user(
203 203 statuses, reviewers)
204 204
205 205 c.commit_review_status = ChangesetStatus.STATUS_NOT_REVIEWED
206 206
207 207 c.commit_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
208 208
209 209 for review_obj, member, reasons, mandatory, status in review_statuses:
210 210 member_reviewer = h.reviewer_as_json(
211 211 member, reasons=reasons, mandatory=mandatory, role=None,
212 212 user_group=None
213 213 )
214 214
215 215 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
216 216 member_reviewer['review_status'] = current_review_status
217 217 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
218 218 member_reviewer['allowed_to_update'] = False
219 219 c.commit_set_reviewers_data_json['reviewers'].append(member_reviewer)
220 220
221 c.commit_set_reviewers_data_json = json.dumps(c.commit_set_reviewers_data_json)
221 c.commit_set_reviewers_data_json = ext_json.str_json(c.commit_set_reviewers_data_json)
222 222
223 223 # NOTE(marcink): this uses the same voting logic as in pull-requests
224 224 c.commit_review_status = ChangesetStatusModel().calculate_status(review_statuses)
225 225 c.commit_broadcast_channel = channelstream.comment_channel(c.repo_name, commit_obj=commit)
226 226
227 227 diff = None
228 228 # Iterate over ranges (default commit view is always one commit)
229 229 for commit in c.commit_ranges:
230 230 c.changes[commit.raw_id] = []
231 231
232 232 commit2 = commit
233 233 commit1 = commit.first_parent
234 234
235 235 if method == 'show':
236 236 inline_comments = CommentsModel().get_inline_comments(
237 237 self.db_repo.repo_id, revision=commit.raw_id)
238 238 c.inline_cnt = len(CommentsModel().get_inline_comments_as_list(
239 239 inline_comments))
240 240 c.inline_comments = inline_comments
241 241
242 242 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
243 243 self.db_repo)
244 244 cache_file_path = diff_cache_exist(
245 245 cache_path, 'diff', commit.raw_id,
246 246 hide_whitespace_changes, diff_context, c.fulldiff)
247 247
248 248 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
249 249 force_recache = str2bool(self.request.GET.get('force_recache'))
250 250
251 251 cached_diff = None
252 252 if caching_enabled:
253 253 cached_diff = load_cached_diff(cache_file_path)
254 254
255 255 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
256 256 if not force_recache and has_proper_diff_cache:
257 257 diffset = cached_diff['diff']
258 258 else:
259 259 vcs_diff = self.rhodecode_vcs_repo.get_diff(
260 260 commit1, commit2,
261 261 ignore_whitespace=hide_whitespace_changes,
262 262 context=diff_context)
263 263
264 264 diff_processor = diffs.DiffProcessor(
265 265 vcs_diff, format='newdiff', diff_limit=diff_limit,
266 266 file_limit=file_limit, show_full_diff=c.fulldiff)
267 267
268 268 _parsed = diff_processor.prepare()
269 269
270 270 diffset = codeblocks.DiffSet(
271 271 repo_name=self.db_repo_name,
272 272 source_node_getter=codeblocks.diffset_node_getter(commit1),
273 273 target_node_getter=codeblocks.diffset_node_getter(commit2))
274 274
275 275 diffset = self.path_filter.render_patchset_filtered(
276 276 diffset, _parsed, commit1.raw_id, commit2.raw_id)
277 277
278 278 # save cached diff
279 279 if caching_enabled:
280 280 cache_diff(cache_file_path, diffset, None)
281 281
282 282 c.limited_diff = diffset.limited_diff
283 283 c.changes[commit.raw_id] = diffset
284 284 else:
285 285 # TODO(marcink): no cache usage here...
286 286 _diff = self.rhodecode_vcs_repo.get_diff(
287 287 commit1, commit2,
288 288 ignore_whitespace=hide_whitespace_changes, context=diff_context)
289 289 diff_processor = diffs.DiffProcessor(
290 290 _diff, format='newdiff', diff_limit=diff_limit,
291 291 file_limit=file_limit, show_full_diff=c.fulldiff)
292 292 # downloads/raw we only need RAW diff nothing else
293 293 diff = self.path_filter.get_raw_patch(diff_processor)
294 294 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
295 295
296 296 # sort comments by how they were generated
297 297 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
298 298 c.at_version_num = None
299 299
300 300 if len(c.commit_ranges) == 1:
301 301 c.commit = c.commit_ranges[0]
302 302 c.parent_tmpl = ''.join(
303 303 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
304 304
305 305 if method == 'download':
306 306 response = Response(diff)
307 307 response.content_type = 'text/plain'
308 308 response.content_disposition = (
309 309 'attachment; filename=%s.diff' % commit_id_range[:12])
310 310 return response
311 311 elif method == 'patch':
312 312 c.diff = safe_unicode(diff)
313 313 patch = render(
314 314 'rhodecode:templates/changeset/patch_changeset.mako',
315 315 self._get_template_context(c), self.request)
316 316 response = Response(patch)
317 317 response.content_type = 'text/plain'
318 318 return response
319 319 elif method == 'raw':
320 320 response = Response(diff)
321 321 response.content_type = 'text/plain'
322 322 return response
323 323 elif method == 'show':
324 324 if len(c.commit_ranges) == 1:
325 325 html = render(
326 326 'rhodecode:templates/changeset/changeset.mako',
327 327 self._get_template_context(c), self.request)
328 328 return Response(html)
329 329 else:
330 330 c.ancestor = None
331 331 c.target_repo = self.db_repo
332 332 html = render(
333 333 'rhodecode:templates/changeset/changeset_range.mako',
334 334 self._get_template_context(c), self.request)
335 335 return Response(html)
336 336
337 337 raise HTTPBadRequest()
338 338
339 339 @LoginRequired()
340 340 @HasRepoPermissionAnyDecorator(
341 341 'repository.read', 'repository.write', 'repository.admin')
342 342 def repo_commit_show(self):
343 343 commit_id = self.request.matchdict['commit_id']
344 344 return self._commit(commit_id, method='show')
345 345
346 346 @LoginRequired()
347 347 @HasRepoPermissionAnyDecorator(
348 348 'repository.read', 'repository.write', 'repository.admin')
349 349 def repo_commit_raw(self):
350 350 commit_id = self.request.matchdict['commit_id']
351 351 return self._commit(commit_id, method='raw')
352 352
353 353 @LoginRequired()
354 354 @HasRepoPermissionAnyDecorator(
355 355 'repository.read', 'repository.write', 'repository.admin')
356 356 def repo_commit_patch(self):
357 357 commit_id = self.request.matchdict['commit_id']
358 358 return self._commit(commit_id, method='patch')
359 359
360 360 @LoginRequired()
361 361 @HasRepoPermissionAnyDecorator(
362 362 'repository.read', 'repository.write', 'repository.admin')
363 363 def repo_commit_download(self):
364 364 commit_id = self.request.matchdict['commit_id']
365 365 return self._commit(commit_id, method='download')
366 366
367 367 def _commit_comments_create(self, commit_id, comments):
368 368 _ = self.request.translate
369 369 data = {}
370 370 if not comments:
371 371 return
372 372
373 373 commit = self.db_repo.get_commit(commit_id)
374 374
375 375 all_drafts = len([x for x in comments if str2bool(x['is_draft'])]) == len(comments)
376 376 for entry in comments:
377 377 c = self.load_default_context()
378 378 comment_type = entry['comment_type']
379 379 text = entry['text']
380 380 status = entry['status']
381 381 is_draft = str2bool(entry['is_draft'])
382 382 resolves_comment_id = entry['resolves_comment_id']
383 383 f_path = entry['f_path']
384 384 line_no = entry['line']
385 385 target_elem_id = 'file-{}'.format(h.safeid(h.safe_unicode(f_path)))
386 386
387 387 if status:
388 388 text = text or (_('Status change %(transition_icon)s %(status)s')
389 389 % {'transition_icon': '>',
390 390 'status': ChangesetStatus.get_status_lbl(status)})
391 391
392 392 comment = CommentsModel().create(
393 393 text=text,
394 394 repo=self.db_repo.repo_id,
395 395 user=self._rhodecode_db_user.user_id,
396 396 commit_id=commit_id,
397 397 f_path=f_path,
398 398 line_no=line_no,
399 399 status_change=(ChangesetStatus.get_status_lbl(status)
400 400 if status else None),
401 401 status_change_type=status,
402 402 comment_type=comment_type,
403 403 is_draft=is_draft,
404 404 resolves_comment_id=resolves_comment_id,
405 405 auth_user=self._rhodecode_user,
406 406 send_email=not is_draft, # skip notification for draft comments
407 407 )
408 408 is_inline = comment.is_inline
409 409
410 410 # get status if set !
411 411 if status:
412 412 # `dont_allow_on_closed_pull_request = True` means
413 413 # if latest status was from pull request and it's closed
414 414 # disallow changing status !
415 415
416 416 try:
417 417 ChangesetStatusModel().set_status(
418 418 self.db_repo.repo_id,
419 419 status,
420 420 self._rhodecode_db_user.user_id,
421 421 comment,
422 422 revision=commit_id,
423 423 dont_allow_on_closed_pull_request=True
424 424 )
425 425 except StatusChangeOnClosedPullRequestError:
426 426 msg = _('Changing the status of a commit associated with '
427 427 'a closed pull request is not allowed')
428 428 log.exception(msg)
429 429 h.flash(msg, category='warning')
430 430 raise HTTPFound(h.route_path(
431 431 'repo_commit', repo_name=self.db_repo_name,
432 432 commit_id=commit_id))
433 433
434 434 Session().flush()
435 435 # this is somehow required to get access to some relationship
436 436 # loaded on comment
437 437 Session().refresh(comment)
438 438
439 439 # skip notifications for drafts
440 440 if not is_draft:
441 441 CommentsModel().trigger_commit_comment_hook(
442 442 self.db_repo, self._rhodecode_user, 'create',
443 443 data={'comment': comment, 'commit': commit})
444 444
445 445 comment_id = comment.comment_id
446 446 data[comment_id] = {
447 447 'target_id': target_elem_id
448 448 }
449 449 Session().flush()
450 450
451 451 c.co = comment
452 452 c.at_version_num = 0
453 453 c.is_new = True
454 454 rendered_comment = render(
455 455 'rhodecode:templates/changeset/changeset_comment_block.mako',
456 456 self._get_template_context(c), self.request)
457 457
458 458 data[comment_id].update(comment.get_dict())
459 459 data[comment_id].update({'rendered_text': rendered_comment})
460 460
461 461 # finalize, commit and redirect
462 462 Session().commit()
463 463
464 464 # skip channelstream for draft comments
465 465 if not all_drafts:
466 466 comment_broadcast_channel = channelstream.comment_channel(
467 467 self.db_repo_name, commit_obj=commit)
468 468
469 469 comment_data = data
470 470 posted_comment_type = 'inline' if is_inline else 'general'
471 471 if len(data) == 1:
472 472 msg = _('posted {} new {} comment').format(len(data), posted_comment_type)
473 473 else:
474 474 msg = _('posted {} new {} comments').format(len(data), posted_comment_type)
475 475
476 476 channelstream.comment_channelstream_push(
477 477 self.request, comment_broadcast_channel, self._rhodecode_user, msg,
478 478 comment_data=comment_data)
479 479
480 480 return data
481 481
482 482 @LoginRequired()
483 483 @NotAnonymous()
484 484 @HasRepoPermissionAnyDecorator(
485 485 'repository.read', 'repository.write', 'repository.admin')
486 486 @CSRFRequired()
487 487 def repo_commit_comment_create(self):
488 488 _ = self.request.translate
489 489 commit_id = self.request.matchdict['commit_id']
490 490
491 491 multi_commit_ids = []
492 492 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
493 493 if _commit_id not in ['', None, EmptyCommit.raw_id]:
494 494 if _commit_id not in multi_commit_ids:
495 495 multi_commit_ids.append(_commit_id)
496 496
497 497 commit_ids = multi_commit_ids or [commit_id]
498 498
499 499 data = []
500 500 # Multiple comments for each passed commit id
501 501 for current_id in filter(None, commit_ids):
502 502 comment_data = {
503 503 'comment_type': self.request.POST.get('comment_type'),
504 504 'text': self.request.POST.get('text'),
505 505 'status': self.request.POST.get('changeset_status', None),
506 506 'is_draft': self.request.POST.get('draft'),
507 507 'resolves_comment_id': self.request.POST.get('resolves_comment_id', None),
508 508 'close_pull_request': self.request.POST.get('close_pull_request'),
509 509 'f_path': self.request.POST.get('f_path'),
510 510 'line': self.request.POST.get('line'),
511 511 }
512 512 comment = self._commit_comments_create(commit_id=current_id, comments=[comment_data])
513 513 data.append(comment)
514 514
515 515 return data if len(data) > 1 else data[0]
516 516
517 517 @LoginRequired()
518 518 @NotAnonymous()
519 519 @HasRepoPermissionAnyDecorator(
520 520 'repository.read', 'repository.write', 'repository.admin')
521 521 @CSRFRequired()
522 522 def repo_commit_comment_preview(self):
523 523 # Technically a CSRF token is not needed as no state changes with this
524 524 # call. However, as this is a POST is better to have it, so automated
525 525 # tools don't flag it as potential CSRF.
526 526 # Post is required because the payload could be bigger than the maximum
527 527 # allowed by GET.
528 528
529 529 text = self.request.POST.get('text')
530 530 renderer = self.request.POST.get('renderer') or 'rst'
531 531 if text:
532 532 return h.render(text, renderer=renderer, mentions=True,
533 533 repo_name=self.db_repo_name)
534 534 return ''
535 535
536 536 @LoginRequired()
537 537 @HasRepoPermissionAnyDecorator(
538 538 'repository.read', 'repository.write', 'repository.admin')
539 539 @CSRFRequired()
540 540 def repo_commit_comment_history_view(self):
541 541 c = self.load_default_context()
542 542 comment_id = self.request.matchdict['comment_id']
543 543 comment_history_id = self.request.matchdict['comment_history_id']
544 544
545 545 comment = ChangesetComment.get_or_404(comment_id)
546 546 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
547 547 if comment.draft and not comment_owner:
548 548 # if we see draft comments history, we only allow this for owner
549 549 raise HTTPNotFound()
550 550
551 551 comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
552 552 is_repo_comment = comment_history.comment.repo.repo_id == self.db_repo.repo_id
553 553
554 554 if is_repo_comment:
555 555 c.comment_history = comment_history
556 556
557 557 rendered_comment = render(
558 558 'rhodecode:templates/changeset/comment_history.mako',
559 559 self._get_template_context(c), self.request)
560 560 return rendered_comment
561 561 else:
562 562 log.warning('No permissions for user %s to show comment_history_id: %s',
563 563 self._rhodecode_db_user, comment_history_id)
564 564 raise HTTPNotFound()
565 565
566 566 @LoginRequired()
567 567 @NotAnonymous()
568 568 @HasRepoPermissionAnyDecorator(
569 569 'repository.read', 'repository.write', 'repository.admin')
570 570 @CSRFRequired()
571 571 def repo_commit_comment_attachment_upload(self):
572 572 c = self.load_default_context()
573 573 upload_key = 'attachment'
574 574
575 575 file_obj = self.request.POST.get(upload_key)
576 576
577 577 if file_obj is None:
578 578 self.request.response.status = 400
579 579 return {'store_fid': None,
580 580 'access_path': None,
581 581 'error': '{} data field is missing'.format(upload_key)}
582 582
583 583 if not hasattr(file_obj, 'filename'):
584 584 self.request.response.status = 400
585 585 return {'store_fid': None,
586 586 'access_path': None,
587 587 'error': 'filename cannot be read from the data field'}
588 588
589 589 filename = file_obj.filename
590 590 file_display_name = filename
591 591
592 592 metadata = {
593 593 'user_uploaded': {'username': self._rhodecode_user.username,
594 594 'user_id': self._rhodecode_user.user_id,
595 595 'ip': self._rhodecode_user.ip_addr}}
596 596
597 597 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
598 598 allowed_extensions = [
599 599 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
600 600 '.pptx', '.txt', '.xlsx', '.zip']
601 601 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
602 602
603 603 try:
604 604 storage = store_utils.get_file_storage(self.request.registry.settings)
605 605 store_uid, metadata = storage.save_file(
606 606 file_obj.file, filename, extra_metadata=metadata,
607 607 extensions=allowed_extensions, max_filesize=max_file_size)
608 608 except FileNotAllowedException:
609 609 self.request.response.status = 400
610 610 permitted_extensions = ', '.join(allowed_extensions)
611 611 error_msg = 'File `{}` is not allowed. ' \
612 612 'Only following extensions are permitted: {}'.format(
613 613 filename, permitted_extensions)
614 614 return {'store_fid': None,
615 615 'access_path': None,
616 616 'error': error_msg}
617 617 except FileOverSizeException:
618 618 self.request.response.status = 400
619 619 limit_mb = h.format_byte_size_binary(max_file_size)
620 620 return {'store_fid': None,
621 621 'access_path': None,
622 622 'error': 'File {} is exceeding allowed limit of {}.'.format(
623 623 filename, limit_mb)}
624 624
625 625 try:
626 626 entry = FileStore.create(
627 627 file_uid=store_uid, filename=metadata["filename"],
628 628 file_hash=metadata["sha256"], file_size=metadata["size"],
629 629 file_display_name=file_display_name,
630 630 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
631 631 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
632 632 scope_repo_id=self.db_repo.repo_id
633 633 )
634 634 Session().add(entry)
635 635 Session().commit()
636 636 log.debug('Stored upload in DB as %s', entry)
637 637 except Exception:
638 638 log.exception('Failed to store file %s', filename)
639 639 self.request.response.status = 400
640 640 return {'store_fid': None,
641 641 'access_path': None,
642 642 'error': 'File {} failed to store in DB.'.format(filename)}
643 643
644 644 Session().commit()
645 645
646 646 return {
647 647 'store_fid': store_uid,
648 648 'access_path': h.route_path(
649 649 'download_file', fid=store_uid),
650 650 'fqn_access_path': h.route_url(
651 651 'download_file', fid=store_uid),
652 652 'repo_access_path': h.route_path(
653 653 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
654 654 'repo_fqn_access_path': h.route_url(
655 655 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
656 656 }
657 657
658 658 @LoginRequired()
659 659 @NotAnonymous()
660 660 @HasRepoPermissionAnyDecorator(
661 661 'repository.read', 'repository.write', 'repository.admin')
662 662 @CSRFRequired()
663 663 def repo_commit_comment_delete(self):
664 664 commit_id = self.request.matchdict['commit_id']
665 665 comment_id = self.request.matchdict['comment_id']
666 666
667 667 comment = ChangesetComment.get_or_404(comment_id)
668 668 if not comment:
669 669 log.debug('Comment with id:%s not found, skipping', comment_id)
670 670 # comment already deleted in another call probably
671 671 return True
672 672
673 673 if comment.immutable:
674 674 # don't allow deleting comments that are immutable
675 675 raise HTTPForbidden()
676 676
677 677 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
678 678 super_admin = h.HasPermissionAny('hg.admin')()
679 679 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
680 680 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
681 681 comment_repo_admin = is_repo_admin and is_repo_comment
682 682
683 683 if comment.draft and not comment_owner:
684 684 # We never allow to delete draft comments for other than owners
685 685 raise HTTPNotFound()
686 686
687 687 if super_admin or comment_owner or comment_repo_admin:
688 688 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
689 689 Session().commit()
690 690 return True
691 691 else:
692 692 log.warning('No permissions for user %s to delete comment_id: %s',
693 693 self._rhodecode_db_user, comment_id)
694 694 raise HTTPNotFound()
695 695
696 696 @LoginRequired()
697 697 @NotAnonymous()
698 698 @HasRepoPermissionAnyDecorator(
699 699 'repository.read', 'repository.write', 'repository.admin')
700 700 @CSRFRequired()
701 701 def repo_commit_comment_edit(self):
702 702 self.load_default_context()
703 703
704 704 commit_id = self.request.matchdict['commit_id']
705 705 comment_id = self.request.matchdict['comment_id']
706 706 comment = ChangesetComment.get_or_404(comment_id)
707 707
708 708 if comment.immutable:
709 709 # don't allow deleting comments that are immutable
710 710 raise HTTPForbidden()
711 711
712 712 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
713 713 super_admin = h.HasPermissionAny('hg.admin')()
714 714 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
715 715 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
716 716 comment_repo_admin = is_repo_admin and is_repo_comment
717 717
718 718 if super_admin or comment_owner or comment_repo_admin:
719 719 text = self.request.POST.get('text')
720 720 version = self.request.POST.get('version')
721 721 if text == comment.text:
722 722 log.warning(
723 723 'Comment(repo): '
724 724 'Trying to create new version '
725 725 'with the same comment body {}'.format(
726 726 comment_id,
727 727 )
728 728 )
729 729 raise HTTPNotFound()
730 730
731 731 if version.isdigit():
732 732 version = int(version)
733 733 else:
734 734 log.warning(
735 735 'Comment(repo): Wrong version type {} {} '
736 736 'for comment {}'.format(
737 737 version,
738 738 type(version),
739 739 comment_id,
740 740 )
741 741 )
742 742 raise HTTPNotFound()
743 743
744 744 try:
745 745 comment_history = CommentsModel().edit(
746 746 comment_id=comment_id,
747 747 text=text,
748 748 auth_user=self._rhodecode_user,
749 749 version=version,
750 750 )
751 751 except CommentVersionMismatch:
752 752 raise HTTPConflict()
753 753
754 754 if not comment_history:
755 755 raise HTTPNotFound()
756 756
757 757 if not comment.draft:
758 758 commit = self.db_repo.get_commit(commit_id)
759 759 CommentsModel().trigger_commit_comment_hook(
760 760 self.db_repo, self._rhodecode_user, 'edit',
761 761 data={'comment': comment, 'commit': commit})
762 762
763 763 Session().commit()
764 764 return {
765 765 'comment_history_id': comment_history.comment_history_id,
766 766 'comment_id': comment.comment_id,
767 767 'comment_version': comment_history.version,
768 768 'comment_author_username': comment_history.author.username,
769 769 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
770 770 'comment_created_on': h.age_component(comment_history.created_on,
771 771 time_is_local=True),
772 772 }
773 773 else:
774 774 log.warning('No permissions for user %s to edit comment_id: %s',
775 775 self._rhodecode_db_user, comment_id)
776 776 raise HTTPNotFound()
777 777
778 778 @LoginRequired()
779 779 @HasRepoPermissionAnyDecorator(
780 780 'repository.read', 'repository.write', 'repository.admin')
781 781 def repo_commit_data(self):
782 782 commit_id = self.request.matchdict['commit_id']
783 783 self.load_default_context()
784 784
785 785 try:
786 786 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
787 787 except CommitDoesNotExistError as e:
788 788 return EmptyCommit(message=str(e))
789 789
790 790 @LoginRequired()
791 791 @HasRepoPermissionAnyDecorator(
792 792 'repository.read', 'repository.write', 'repository.admin')
793 793 def repo_commit_children(self):
794 794 commit_id = self.request.matchdict['commit_id']
795 795 self.load_default_context()
796 796
797 797 try:
798 798 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
799 799 children = commit.children
800 800 except CommitDoesNotExistError:
801 801 children = []
802 802
803 803 result = {"results": children}
804 804 return result
805 805
806 806 @LoginRequired()
807 807 @HasRepoPermissionAnyDecorator(
808 808 'repository.read', 'repository.write', 'repository.admin')
809 809 def repo_commit_parents(self):
810 810 commit_id = self.request.matchdict['commit_id']
811 811 self.load_default_context()
812 812
813 813 try:
814 814 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
815 815 parents = commit.parents
816 816 except CommitDoesNotExistError:
817 817 parents = []
818 818 result = {"results": parents}
819 819 return result
@@ -1,1877 +1,1877 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest, HTTPConflict)
29 29
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
33 33
34 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
35 35 from rhodecode.lib.base import vcs_operation_context
36 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
37 37 from rhodecode.lib.exceptions import CommentVersionMismatch
38 from rhodecode.lib.ext_json import json
38 from rhodecode.lib import ext_json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode, safe_int, aslist, retry
43 43 from rhodecode.lib.vcs.backends.base import (
44 44 EmptyCommit, UpdateFailureReason, unicode_to_reference)
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError)
47 47 from rhodecode.model.changeset_status import ChangesetStatusModel
48 48 from rhodecode.model.comment import CommentsModel
49 49 from rhodecode.model.db import (
50 50 func, false, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository,
51 51 PullRequestReviewers)
52 52 from rhodecode.model.forms import PullRequestForm
53 53 from rhodecode.model.meta import Session
54 54 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
55 55 from rhodecode.model.scm import ScmModel
56 56
57 57 log = logging.getLogger(__name__)
58 58
59 59
60 60 class RepoPullRequestsView(RepoAppView, DataGridAppView):
61 61
62 62 def load_default_context(self):
63 63 c = self._get_local_tmpl_context(include_app_defaults=True)
64 64 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
65 65 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
66 66 # backward compat., we use for OLD PRs a plain renderer
67 67 c.renderer = 'plain'
68 68 return c
69 69
70 70 def _get_pull_requests_list(
71 71 self, repo_name, source, filter_type, opened_by, statuses):
72 72
73 73 draw, start, limit = self._extract_chunk(self.request)
74 74 search_q, order_by, order_dir = self._extract_ordering(self.request)
75 75 _render = self.request.get_partial_renderer(
76 76 'rhodecode:templates/data_table/_dt_elements.mako')
77 77
78 78 # pagination
79 79
80 80 if filter_type == 'awaiting_review':
81 81 pull_requests = PullRequestModel().get_awaiting_review(
82 82 repo_name,
83 83 search_q=search_q, statuses=statuses,
84 84 offset=start, length=limit, order_by=order_by, order_dir=order_dir)
85 85 pull_requests_total_count = PullRequestModel().count_awaiting_review(
86 86 repo_name,
87 87 search_q=search_q, statuses=statuses)
88 88 elif filter_type == 'awaiting_my_review':
89 89 pull_requests = PullRequestModel().get_awaiting_my_review(
90 90 repo_name, self._rhodecode_user.user_id,
91 91 search_q=search_q, statuses=statuses,
92 92 offset=start, length=limit, order_by=order_by, order_dir=order_dir)
93 93 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
94 94 repo_name, self._rhodecode_user.user_id,
95 95 search_q=search_q, statuses=statuses)
96 96 else:
97 97 pull_requests = PullRequestModel().get_all(
98 98 repo_name, search_q=search_q, source=source, opened_by=opened_by,
99 99 statuses=statuses, offset=start, length=limit,
100 100 order_by=order_by, order_dir=order_dir)
101 101 pull_requests_total_count = PullRequestModel().count_all(
102 102 repo_name, search_q=search_q, source=source, statuses=statuses,
103 103 opened_by=opened_by)
104 104
105 105 data = []
106 106 comments_model = CommentsModel()
107 107 for pr in pull_requests:
108 108 comments_count = comments_model.get_all_comments(
109 109 self.db_repo.repo_id, pull_request=pr,
110 110 include_drafts=False, count_only=True)
111 111
112 112 review_statuses = pr.reviewers_statuses(user=self._rhodecode_db_user)
113 113 my_review_status = ChangesetStatus.STATUS_NOT_REVIEWED
114 114 if review_statuses and review_statuses[4]:
115 115 _review_obj, _user, _reasons, _mandatory, statuses = review_statuses
116 116 my_review_status = statuses[0][1].status
117 117
118 118 data.append({
119 119 'name': _render('pullrequest_name',
120 120 pr.pull_request_id, pr.pull_request_state,
121 121 pr.work_in_progress, pr.target_repo.repo_name,
122 122 short=True),
123 123 'name_raw': pr.pull_request_id,
124 124 'status': _render('pullrequest_status',
125 125 pr.calculated_review_status()),
126 126 'my_status': _render('pullrequest_status',
127 127 my_review_status),
128 128 'title': _render('pullrequest_title', pr.title, pr.description),
129 129 'description': h.escape(pr.description),
130 130 'updated_on': _render('pullrequest_updated_on',
131 131 h.datetime_to_time(pr.updated_on),
132 132 pr.versions_count),
133 133 'updated_on_raw': h.datetime_to_time(pr.updated_on),
134 134 'created_on': _render('pullrequest_updated_on',
135 135 h.datetime_to_time(pr.created_on)),
136 136 'created_on_raw': h.datetime_to_time(pr.created_on),
137 137 'state': pr.pull_request_state,
138 138 'author': _render('pullrequest_author',
139 139 pr.author.full_contact, ),
140 140 'author_raw': pr.author.full_name,
141 141 'comments': _render('pullrequest_comments', comments_count),
142 142 'comments_raw': comments_count,
143 143 'closed': pr.is_closed(),
144 144 })
145 145
146 146 data = ({
147 147 'draw': draw,
148 148 'data': data,
149 149 'recordsTotal': pull_requests_total_count,
150 150 'recordsFiltered': pull_requests_total_count,
151 151 })
152 152 return data
153 153
154 154 @LoginRequired()
155 155 @HasRepoPermissionAnyDecorator(
156 156 'repository.read', 'repository.write', 'repository.admin')
157 157 def pull_request_list(self):
158 158 c = self.load_default_context()
159 159
160 160 req_get = self.request.GET
161 161 c.source = str2bool(req_get.get('source'))
162 162 c.closed = str2bool(req_get.get('closed'))
163 163 c.my = str2bool(req_get.get('my'))
164 164 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
165 165 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
166 166
167 167 c.active = 'open'
168 168 if c.my:
169 169 c.active = 'my'
170 170 if c.closed:
171 171 c.active = 'closed'
172 172 if c.awaiting_review and not c.source:
173 173 c.active = 'awaiting'
174 174 if c.source and not c.awaiting_review:
175 175 c.active = 'source'
176 176 if c.awaiting_my_review:
177 177 c.active = 'awaiting_my'
178 178
179 179 return self._get_template_context(c)
180 180
181 181 @LoginRequired()
182 182 @HasRepoPermissionAnyDecorator(
183 183 'repository.read', 'repository.write', 'repository.admin')
184 184 def pull_request_list_data(self):
185 185 self.load_default_context()
186 186
187 187 # additional filters
188 188 req_get = self.request.GET
189 189 source = str2bool(req_get.get('source'))
190 190 closed = str2bool(req_get.get('closed'))
191 191 my = str2bool(req_get.get('my'))
192 192 awaiting_review = str2bool(req_get.get('awaiting_review'))
193 193 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
194 194
195 195 filter_type = 'awaiting_review' if awaiting_review \
196 196 else 'awaiting_my_review' if awaiting_my_review \
197 197 else None
198 198
199 199 opened_by = None
200 200 if my:
201 201 opened_by = [self._rhodecode_user.user_id]
202 202
203 203 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
204 204 if closed:
205 205 statuses = [PullRequest.STATUS_CLOSED]
206 206
207 207 data = self._get_pull_requests_list(
208 208 repo_name=self.db_repo_name, source=source,
209 209 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
210 210
211 211 return data
212 212
213 213 def _is_diff_cache_enabled(self, target_repo):
214 214 caching_enabled = self._get_general_setting(
215 215 target_repo, 'rhodecode_diff_cache')
216 216 log.debug('Diff caching enabled: %s', caching_enabled)
217 217 return caching_enabled
218 218
219 219 def _get_diffset(self, source_repo_name, source_repo,
220 220 ancestor_commit,
221 221 source_ref_id, target_ref_id,
222 222 target_commit, source_commit, diff_limit, file_limit,
223 223 fulldiff, hide_whitespace_changes, diff_context, use_ancestor=True):
224 224
225 225 target_commit_final = target_commit
226 226 source_commit_final = source_commit
227 227
228 228 if use_ancestor:
229 229 # we might want to not use it for versions
230 230 target_ref_id = ancestor_commit.raw_id
231 231 target_commit_final = ancestor_commit
232 232
233 233 vcs_diff = PullRequestModel().get_diff(
234 234 source_repo, source_ref_id, target_ref_id,
235 235 hide_whitespace_changes, diff_context)
236 236
237 237 diff_processor = diffs.DiffProcessor(
238 238 vcs_diff, format='newdiff', diff_limit=diff_limit,
239 239 file_limit=file_limit, show_full_diff=fulldiff)
240 240
241 241 _parsed = diff_processor.prepare()
242 242
243 243 diffset = codeblocks.DiffSet(
244 244 repo_name=self.db_repo_name,
245 245 source_repo_name=source_repo_name,
246 246 source_node_getter=codeblocks.diffset_node_getter(target_commit_final),
247 247 target_node_getter=codeblocks.diffset_node_getter(source_commit_final),
248 248 )
249 249 diffset = self.path_filter.render_patchset_filtered(
250 250 diffset, _parsed, target_ref_id, source_ref_id)
251 251
252 252 return diffset
253 253
254 254 def _get_range_diffset(self, source_scm, source_repo,
255 255 commit1, commit2, diff_limit, file_limit,
256 256 fulldiff, hide_whitespace_changes, diff_context):
257 257 vcs_diff = source_scm.get_diff(
258 258 commit1, commit2,
259 259 ignore_whitespace=hide_whitespace_changes,
260 260 context=diff_context)
261 261
262 262 diff_processor = diffs.DiffProcessor(
263 263 vcs_diff, format='newdiff', diff_limit=diff_limit,
264 264 file_limit=file_limit, show_full_diff=fulldiff)
265 265
266 266 _parsed = diff_processor.prepare()
267 267
268 268 diffset = codeblocks.DiffSet(
269 269 repo_name=source_repo.repo_name,
270 270 source_node_getter=codeblocks.diffset_node_getter(commit1),
271 271 target_node_getter=codeblocks.diffset_node_getter(commit2))
272 272
273 273 diffset = self.path_filter.render_patchset_filtered(
274 274 diffset, _parsed, commit1.raw_id, commit2.raw_id)
275 275
276 276 return diffset
277 277
278 278 def register_comments_vars(self, c, pull_request, versions, include_drafts=True):
279 279 comments_model = CommentsModel()
280 280
281 281 # GENERAL COMMENTS with versions #
282 282 q = comments_model._all_general_comments_of_pull_request(pull_request)
283 283 q = q.order_by(ChangesetComment.comment_id.asc())
284 284 if not include_drafts:
285 285 q = q.filter(ChangesetComment.draft == false())
286 286 general_comments = q
287 287
288 288 # pick comments we want to render at current version
289 289 c.comment_versions = comments_model.aggregate_comments(
290 290 general_comments, versions, c.at_version_num)
291 291
292 292 # INLINE COMMENTS with versions #
293 293 q = comments_model._all_inline_comments_of_pull_request(pull_request)
294 294 q = q.order_by(ChangesetComment.comment_id.asc())
295 295 if not include_drafts:
296 296 q = q.filter(ChangesetComment.draft == false())
297 297 inline_comments = q
298 298
299 299 c.inline_versions = comments_model.aggregate_comments(
300 300 inline_comments, versions, c.at_version_num, inline=True)
301 301
302 302 # Comments inline+general
303 303 if c.at_version:
304 304 c.inline_comments_flat = c.inline_versions[c.at_version_num]['display']
305 305 c.comments = c.comment_versions[c.at_version_num]['display']
306 306 else:
307 307 c.inline_comments_flat = c.inline_versions[c.at_version_num]['until']
308 308 c.comments = c.comment_versions[c.at_version_num]['until']
309 309
310 310 return general_comments, inline_comments
311 311
312 312 @LoginRequired()
313 313 @HasRepoPermissionAnyDecorator(
314 314 'repository.read', 'repository.write', 'repository.admin')
315 315 def pull_request_show(self):
316 316 _ = self.request.translate
317 317 c = self.load_default_context()
318 318
319 319 pull_request = PullRequest.get_or_404(
320 320 self.request.matchdict['pull_request_id'])
321 321 pull_request_id = pull_request.pull_request_id
322 322
323 323 c.state_progressing = pull_request.is_state_changing()
324 324 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
325 325
326 326 _new_state = {
327 327 'created': PullRequest.STATE_CREATED,
328 328 }.get(self.request.GET.get('force_state'))
329 329 can_force_state = c.is_super_admin or HasRepoPermissionAny('repository.admin')(c.repo_name)
330 330
331 331 if can_force_state and _new_state:
332 332 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
333 333 h.flash(
334 334 _('Pull Request state was force changed to `{}`').format(_new_state),
335 335 category='success')
336 336 Session().commit()
337 337
338 338 raise HTTPFound(h.route_path(
339 339 'pullrequest_show', repo_name=self.db_repo_name,
340 340 pull_request_id=pull_request_id))
341 341
342 342 version = self.request.GET.get('version')
343 343 from_version = self.request.GET.get('from_version') or version
344 344 merge_checks = self.request.GET.get('merge_checks')
345 345 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
346 346 force_refresh = str2bool(self.request.GET.get('force_refresh'))
347 347 c.range_diff_on = self.request.GET.get('range-diff') == "1"
348 348
349 349 # fetch global flags of ignore ws or context lines
350 350 diff_context = diffs.get_diff_context(self.request)
351 351 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
352 352
353 353 (pull_request_latest,
354 354 pull_request_at_ver,
355 355 pull_request_display_obj,
356 356 at_version) = PullRequestModel().get_pr_version(
357 357 pull_request_id, version=version)
358 358
359 359 pr_closed = pull_request_latest.is_closed()
360 360
361 361 if pr_closed and (version or from_version):
362 362 # not allow to browse versions for closed PR
363 363 raise HTTPFound(h.route_path(
364 364 'pullrequest_show', repo_name=self.db_repo_name,
365 365 pull_request_id=pull_request_id))
366 366
367 367 versions = pull_request_display_obj.versions()
368 368
369 369 c.commit_versions = PullRequestModel().pr_commits_versions(versions)
370 370
371 371 # used to store per-commit range diffs
372 372 c.changes = collections.OrderedDict()
373 373
374 374 c.at_version = at_version
375 375 c.at_version_num = (at_version
376 376 if at_version and at_version != PullRequest.LATEST_VER
377 377 else None)
378 378
379 379 c.at_version_index = ChangesetComment.get_index_from_version(
380 380 c.at_version_num, versions)
381 381
382 382 (prev_pull_request_latest,
383 383 prev_pull_request_at_ver,
384 384 prev_pull_request_display_obj,
385 385 prev_at_version) = PullRequestModel().get_pr_version(
386 386 pull_request_id, version=from_version)
387 387
388 388 c.from_version = prev_at_version
389 389 c.from_version_num = (prev_at_version
390 390 if prev_at_version and prev_at_version != PullRequest.LATEST_VER
391 391 else None)
392 392 c.from_version_index = ChangesetComment.get_index_from_version(
393 393 c.from_version_num, versions)
394 394
395 395 # define if we're in COMPARE mode or VIEW at version mode
396 396 compare = at_version != prev_at_version
397 397
398 398 # pull_requests repo_name we opened it against
399 399 # ie. target_repo must match
400 400 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
401 401 log.warning('Mismatch between the current repo: %s, and target %s',
402 402 self.db_repo_name, pull_request_at_ver.target_repo.repo_name)
403 403 raise HTTPNotFound()
404 404
405 405 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(pull_request_at_ver)
406 406
407 407 c.pull_request = pull_request_display_obj
408 408 c.renderer = pull_request_at_ver.description_renderer or c.renderer
409 409 c.pull_request_latest = pull_request_latest
410 410
411 411 # inject latest version
412 412 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
413 413 c.versions = versions + [latest_ver]
414 414
415 415 if compare or (at_version and not at_version == PullRequest.LATEST_VER):
416 416 c.allowed_to_change_status = False
417 417 c.allowed_to_update = False
418 418 c.allowed_to_merge = False
419 419 c.allowed_to_delete = False
420 420 c.allowed_to_comment = False
421 421 c.allowed_to_close = False
422 422 else:
423 423 can_change_status = PullRequestModel().check_user_change_status(
424 424 pull_request_at_ver, self._rhodecode_user)
425 425 c.allowed_to_change_status = can_change_status and not pr_closed
426 426
427 427 c.allowed_to_update = PullRequestModel().check_user_update(
428 428 pull_request_latest, self._rhodecode_user) and not pr_closed
429 429 c.allowed_to_merge = PullRequestModel().check_user_merge(
430 430 pull_request_latest, self._rhodecode_user) and not pr_closed
431 431 c.allowed_to_delete = PullRequestModel().check_user_delete(
432 432 pull_request_latest, self._rhodecode_user) and not pr_closed
433 433 c.allowed_to_comment = not pr_closed
434 434 c.allowed_to_close = c.allowed_to_merge and not pr_closed
435 435
436 436 c.forbid_adding_reviewers = False
437 437
438 438 if pull_request_latest.reviewer_data and \
439 439 'rules' in pull_request_latest.reviewer_data:
440 440 rules = pull_request_latest.reviewer_data['rules'] or {}
441 441 try:
442 442 c.forbid_adding_reviewers = rules.get('forbid_adding_reviewers')
443 443 except Exception:
444 444 pass
445 445
446 446 # check merge capabilities
447 447 _merge_check = MergeCheck.validate(
448 448 pull_request_latest, auth_user=self._rhodecode_user,
449 449 translator=self.request.translate,
450 450 force_shadow_repo_refresh=force_refresh)
451 451
452 452 c.pr_merge_errors = _merge_check.error_details
453 453 c.pr_merge_possible = not _merge_check.failed
454 454 c.pr_merge_message = _merge_check.merge_msg
455 455 c.pr_merge_source_commit = _merge_check.source_commit
456 456 c.pr_merge_target_commit = _merge_check.target_commit
457 457
458 458 c.pr_merge_info = MergeCheck.get_merge_conditions(
459 459 pull_request_latest, translator=self.request.translate)
460 460
461 461 c.pull_request_review_status = _merge_check.review_status
462 462 if merge_checks:
463 463 self.request.override_renderer = \
464 464 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
465 465 return self._get_template_context(c)
466 466
467 467 c.reviewers_count = pull_request.reviewers_count
468 468 c.observers_count = pull_request.observers_count
469 469
470 470 # reviewers and statuses
471 c.pull_request_default_reviewers_data_json = json.dumps(pull_request.reviewer_data)
471 c.pull_request_default_reviewers_data_json = ext_json.str_json(pull_request.reviewer_data)
472 472 c.pull_request_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
473 473 c.pull_request_set_observers_data_json = collections.OrderedDict({'observers': []})
474 474
475 475 for review_obj, member, reasons, mandatory, status in pull_request_at_ver.reviewers_statuses():
476 476 member_reviewer = h.reviewer_as_json(
477 477 member, reasons=reasons, mandatory=mandatory,
478 478 role=review_obj.role,
479 479 user_group=review_obj.rule_user_group_data()
480 480 )
481 481
482 482 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
483 483 member_reviewer['review_status'] = current_review_status
484 484 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
485 485 member_reviewer['allowed_to_update'] = c.allowed_to_update
486 486 c.pull_request_set_reviewers_data_json['reviewers'].append(member_reviewer)
487 487
488 c.pull_request_set_reviewers_data_json = json.dumps(c.pull_request_set_reviewers_data_json)
488 c.pull_request_set_reviewers_data_json = ext_json.str_json(c.pull_request_set_reviewers_data_json)
489 489
490 490 for observer_obj, member in pull_request_at_ver.observers():
491 491 member_observer = h.reviewer_as_json(
492 492 member, reasons=[], mandatory=False,
493 493 role=observer_obj.role,
494 494 user_group=observer_obj.rule_user_group_data()
495 495 )
496 496 member_observer['allowed_to_update'] = c.allowed_to_update
497 497 c.pull_request_set_observers_data_json['observers'].append(member_observer)
498 498
499 c.pull_request_set_observers_data_json = json.dumps(c.pull_request_set_observers_data_json)
499 c.pull_request_set_observers_data_json = ext_json.str_json(c.pull_request_set_observers_data_json)
500 500
501 501 general_comments, inline_comments = \
502 502 self.register_comments_vars(c, pull_request_latest, versions)
503 503
504 504 # TODOs
505 505 c.unresolved_comments = CommentsModel() \
506 506 .get_pull_request_unresolved_todos(pull_request_latest)
507 507 c.resolved_comments = CommentsModel() \
508 508 .get_pull_request_resolved_todos(pull_request_latest)
509 509
510 510 # Drafts
511 511 c.draft_comments = CommentsModel().get_pull_request_drafts(
512 512 self._rhodecode_db_user.user_id,
513 513 pull_request_latest)
514 514
515 515 # if we use version, then do not show later comments
516 516 # than current version
517 517 display_inline_comments = collections.defaultdict(
518 518 lambda: collections.defaultdict(list))
519 519 for co in inline_comments:
520 520 if c.at_version_num:
521 521 # pick comments that are at least UPTO given version, so we
522 522 # don't render comments for higher version
523 523 should_render = co.pull_request_version_id and \
524 524 co.pull_request_version_id <= c.at_version_num
525 525 else:
526 526 # showing all, for 'latest'
527 527 should_render = True
528 528
529 529 if should_render:
530 530 display_inline_comments[co.f_path][co.line_no].append(co)
531 531
532 532 # load diff data into template context, if we use compare mode then
533 533 # diff is calculated based on changes between versions of PR
534 534
535 535 source_repo = pull_request_at_ver.source_repo
536 536 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
537 537
538 538 target_repo = pull_request_at_ver.target_repo
539 539 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
540 540
541 541 if compare:
542 542 # in compare switch the diff base to latest commit from prev version
543 543 target_ref_id = prev_pull_request_display_obj.revisions[0]
544 544
545 545 # despite opening commits for bookmarks/branches/tags, we always
546 546 # convert this to rev to prevent changes after bookmark or branch change
547 547 c.source_ref_type = 'rev'
548 548 c.source_ref = source_ref_id
549 549
550 550 c.target_ref_type = 'rev'
551 551 c.target_ref = target_ref_id
552 552
553 553 c.source_repo = source_repo
554 554 c.target_repo = target_repo
555 555
556 556 c.commit_ranges = []
557 557 source_commit = EmptyCommit()
558 558 target_commit = EmptyCommit()
559 559 c.missing_requirements = False
560 560
561 561 source_scm = source_repo.scm_instance()
562 562 target_scm = target_repo.scm_instance()
563 563
564 564 shadow_scm = None
565 565 try:
566 566 shadow_scm = pull_request_latest.get_shadow_repo()
567 567 except Exception:
568 568 log.debug('Failed to get shadow repo', exc_info=True)
569 569 # try first the existing source_repo, and then shadow
570 570 # repo if we can obtain one
571 571 commits_source_repo = source_scm
572 572 if shadow_scm:
573 573 commits_source_repo = shadow_scm
574 574
575 575 c.commits_source_repo = commits_source_repo
576 576 c.ancestor = None # set it to None, to hide it from PR view
577 577
578 578 # empty version means latest, so we keep this to prevent
579 579 # double caching
580 580 version_normalized = version or PullRequest.LATEST_VER
581 581 from_version_normalized = from_version or PullRequest.LATEST_VER
582 582
583 583 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
584 584 cache_file_path = diff_cache_exist(
585 585 cache_path, 'pull_request', pull_request_id, version_normalized,
586 586 from_version_normalized, source_ref_id, target_ref_id,
587 587 hide_whitespace_changes, diff_context, c.fulldiff)
588 588
589 589 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
590 590 force_recache = self.get_recache_flag()
591 591
592 592 cached_diff = None
593 593 if caching_enabled:
594 594 cached_diff = load_cached_diff(cache_file_path)
595 595
596 596 has_proper_commit_cache = (
597 597 cached_diff and cached_diff.get('commits')
598 598 and len(cached_diff.get('commits', [])) == 5
599 599 and cached_diff.get('commits')[0]
600 600 and cached_diff.get('commits')[3])
601 601
602 602 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
603 603 diff_commit_cache = \
604 604 (ancestor_commit, commit_cache, missing_requirements,
605 605 source_commit, target_commit) = cached_diff['commits']
606 606 else:
607 607 # NOTE(marcink): we reach potentially unreachable errors when a PR has
608 608 # merge errors resulting in potentially hidden commits in the shadow repo.
609 609 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
610 610 and _merge_check.merge_response
611 611 maybe_unreachable = maybe_unreachable \
612 612 and _merge_check.merge_response.metadata.get('unresolved_files')
613 613 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
614 614 diff_commit_cache = \
615 615 (ancestor_commit, commit_cache, missing_requirements,
616 616 source_commit, target_commit) = self.get_commits(
617 617 commits_source_repo,
618 618 pull_request_at_ver,
619 619 source_commit,
620 620 source_ref_id,
621 621 source_scm,
622 622 target_commit,
623 623 target_ref_id,
624 624 target_scm,
625 625 maybe_unreachable=maybe_unreachable)
626 626
627 627 # register our commit range
628 628 for comm in commit_cache.values():
629 629 c.commit_ranges.append(comm)
630 630
631 631 c.missing_requirements = missing_requirements
632 632 c.ancestor_commit = ancestor_commit
633 633 c.statuses = source_repo.statuses(
634 634 [x.raw_id for x in c.commit_ranges])
635 635
636 636 # auto collapse if we have more than limit
637 637 collapse_limit = diffs.DiffProcessor._collapse_commits_over
638 638 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
639 639 c.compare_mode = compare
640 640
641 641 # diff_limit is the old behavior, will cut off the whole diff
642 642 # if the limit is applied otherwise will just hide the
643 643 # big files from the front-end
644 644 diff_limit = c.visual.cut_off_limit_diff
645 645 file_limit = c.visual.cut_off_limit_file
646 646
647 647 c.missing_commits = False
648 648 if (c.missing_requirements
649 649 or isinstance(source_commit, EmptyCommit)
650 650 or source_commit == target_commit):
651 651
652 652 c.missing_commits = True
653 653 else:
654 654 c.inline_comments = display_inline_comments
655 655
656 656 use_ancestor = True
657 657 if from_version_normalized != version_normalized:
658 658 use_ancestor = False
659 659
660 660 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
661 661 if not force_recache and has_proper_diff_cache:
662 662 c.diffset = cached_diff['diff']
663 663 else:
664 664 try:
665 665 c.diffset = self._get_diffset(
666 666 c.source_repo.repo_name, commits_source_repo,
667 667 c.ancestor_commit,
668 668 source_ref_id, target_ref_id,
669 669 target_commit, source_commit,
670 670 diff_limit, file_limit, c.fulldiff,
671 671 hide_whitespace_changes, diff_context,
672 672 use_ancestor=use_ancestor
673 673 )
674 674
675 675 # save cached diff
676 676 if caching_enabled:
677 677 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
678 678 except CommitDoesNotExistError:
679 679 log.exception('Failed to generate diffset')
680 680 c.missing_commits = True
681 681
682 682 if not c.missing_commits:
683 683
684 684 c.limited_diff = c.diffset.limited_diff
685 685
686 686 # calculate removed files that are bound to comments
687 687 comment_deleted_files = [
688 688 fname for fname in display_inline_comments
689 689 if fname not in c.diffset.file_stats]
690 690
691 691 c.deleted_files_comments = collections.defaultdict(dict)
692 692 for fname, per_line_comments in display_inline_comments.items():
693 693 if fname in comment_deleted_files:
694 694 c.deleted_files_comments[fname]['stats'] = 0
695 695 c.deleted_files_comments[fname]['comments'] = list()
696 696 for lno, comments in per_line_comments.items():
697 697 c.deleted_files_comments[fname]['comments'].extend(comments)
698 698
699 699 # maybe calculate the range diff
700 700 if c.range_diff_on:
701 701 # TODO(marcink): set whitespace/context
702 702 context_lcl = 3
703 703 ign_whitespace_lcl = False
704 704
705 705 for commit in c.commit_ranges:
706 706 commit2 = commit
707 707 commit1 = commit.first_parent
708 708
709 709 range_diff_cache_file_path = diff_cache_exist(
710 710 cache_path, 'diff', commit.raw_id,
711 711 ign_whitespace_lcl, context_lcl, c.fulldiff)
712 712
713 713 cached_diff = None
714 714 if caching_enabled:
715 715 cached_diff = load_cached_diff(range_diff_cache_file_path)
716 716
717 717 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
718 718 if not force_recache and has_proper_diff_cache:
719 719 diffset = cached_diff['diff']
720 720 else:
721 721 diffset = self._get_range_diffset(
722 722 commits_source_repo, source_repo,
723 723 commit1, commit2, diff_limit, file_limit,
724 724 c.fulldiff, ign_whitespace_lcl, context_lcl
725 725 )
726 726
727 727 # save cached diff
728 728 if caching_enabled:
729 729 cache_diff(range_diff_cache_file_path, diffset, None)
730 730
731 731 c.changes[commit.raw_id] = diffset
732 732
733 733 # this is a hack to properly display links, when creating PR, the
734 734 # compare view and others uses different notation, and
735 735 # compare_commits.mako renders links based on the target_repo.
736 736 # We need to swap that here to generate it properly on the html side
737 737 c.target_repo = c.source_repo
738 738
739 739 c.commit_statuses = ChangesetStatus.STATUSES
740 740
741 741 c.show_version_changes = not pr_closed
742 742 if c.show_version_changes:
743 743 cur_obj = pull_request_at_ver
744 744 prev_obj = prev_pull_request_at_ver
745 745
746 746 old_commit_ids = prev_obj.revisions
747 747 new_commit_ids = cur_obj.revisions
748 748 commit_changes = PullRequestModel()._calculate_commit_id_changes(
749 749 old_commit_ids, new_commit_ids)
750 750 c.commit_changes_summary = commit_changes
751 751
752 752 # calculate the diff for commits between versions
753 753 c.commit_changes = []
754 754
755 755 def mark(cs, fw):
756 756 return list(h.itertools.zip_longest([], cs, fillvalue=fw))
757 757
758 758 for c_type, raw_id in mark(commit_changes.added, 'a') \
759 759 + mark(commit_changes.removed, 'r') \
760 760 + mark(commit_changes.common, 'c'):
761 761
762 762 if raw_id in commit_cache:
763 763 commit = commit_cache[raw_id]
764 764 else:
765 765 try:
766 766 commit = commits_source_repo.get_commit(raw_id)
767 767 except CommitDoesNotExistError:
768 768 # in case we fail extracting still use "dummy" commit
769 769 # for display in commit diff
770 770 commit = h.AttributeDict(
771 771 {'raw_id': raw_id,
772 772 'message': 'EMPTY or MISSING COMMIT'})
773 773 c.commit_changes.append([c_type, commit])
774 774
775 775 # current user review statuses for each version
776 776 c.review_versions = {}
777 777 is_reviewer = PullRequestModel().is_user_reviewer(
778 778 pull_request, self._rhodecode_user)
779 779 if is_reviewer:
780 780 for co in general_comments:
781 781 if co.author.user_id == self._rhodecode_user.user_id:
782 782 status = co.status_change
783 783 if status:
784 784 _ver_pr = status[0].comment.pull_request_version_id
785 785 c.review_versions[_ver_pr] = status[0]
786 786
787 787 return self._get_template_context(c)
788 788
789 789 def get_commits(
790 790 self, commits_source_repo, pull_request_at_ver, source_commit,
791 791 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
792 792 maybe_unreachable=False):
793 793
794 794 commit_cache = collections.OrderedDict()
795 795 missing_requirements = False
796 796
797 797 try:
798 798 pre_load = ["author", "date", "message", "branch", "parents"]
799 799
800 800 pull_request_commits = pull_request_at_ver.revisions
801 801 log.debug('Loading %s commits from %s',
802 802 len(pull_request_commits), commits_source_repo)
803 803
804 804 for rev in pull_request_commits:
805 805 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
806 806 maybe_unreachable=maybe_unreachable)
807 807 commit_cache[comm.raw_id] = comm
808 808
809 809 # Order here matters, we first need to get target, and then
810 810 # the source
811 811 target_commit = commits_source_repo.get_commit(
812 812 commit_id=safe_str(target_ref_id))
813 813
814 814 source_commit = commits_source_repo.get_commit(
815 815 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
816 816 except CommitDoesNotExistError:
817 817 log.warning('Failed to get commit from `{}` repo'.format(
818 818 commits_source_repo), exc_info=True)
819 819 except RepositoryRequirementError:
820 820 log.warning('Failed to get all required data from repo', exc_info=True)
821 821 missing_requirements = True
822 822
823 823 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
824 824
825 825 try:
826 826 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
827 827 except Exception:
828 828 ancestor_commit = None
829 829
830 830 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
831 831
832 832 def assure_not_empty_repo(self):
833 833 _ = self.request.translate
834 834
835 835 try:
836 836 self.db_repo.scm_instance().get_commit()
837 837 except EmptyRepositoryError:
838 838 h.flash(h.literal(_('There are no commits yet')),
839 839 category='warning')
840 840 raise HTTPFound(
841 841 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
842 842
843 843 @LoginRequired()
844 844 @NotAnonymous()
845 845 @HasRepoPermissionAnyDecorator(
846 846 'repository.read', 'repository.write', 'repository.admin')
847 847 def pull_request_new(self):
848 848 _ = self.request.translate
849 849 c = self.load_default_context()
850 850
851 851 self.assure_not_empty_repo()
852 852 source_repo = self.db_repo
853 853
854 854 commit_id = self.request.GET.get('commit')
855 855 branch_ref = self.request.GET.get('branch')
856 856 bookmark_ref = self.request.GET.get('bookmark')
857 857
858 858 try:
859 859 source_repo_data = PullRequestModel().generate_repo_data(
860 860 source_repo, commit_id=commit_id,
861 861 branch=branch_ref, bookmark=bookmark_ref,
862 862 translator=self.request.translate)
863 863 except CommitDoesNotExistError as e:
864 864 log.exception(e)
865 865 h.flash(_('Commit does not exist'), 'error')
866 866 raise HTTPFound(
867 867 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
868 868
869 869 default_target_repo = source_repo
870 870
871 871 if source_repo.parent and c.has_origin_repo_read_perm:
872 872 parent_vcs_obj = source_repo.parent.scm_instance()
873 873 if parent_vcs_obj and not parent_vcs_obj.is_empty():
874 874 # change default if we have a parent repo
875 875 default_target_repo = source_repo.parent
876 876
877 877 target_repo_data = PullRequestModel().generate_repo_data(
878 878 default_target_repo, translator=self.request.translate)
879 879
880 880 selected_source_ref = source_repo_data['refs']['selected_ref']
881 881 title_source_ref = ''
882 882 if selected_source_ref:
883 883 title_source_ref = selected_source_ref.split(':', 2)[1]
884 884 c.default_title = PullRequestModel().generate_pullrequest_title(
885 885 source=source_repo.repo_name,
886 886 source_ref=title_source_ref,
887 887 target=default_target_repo.repo_name
888 888 )
889 889
890 890 c.default_repo_data = {
891 891 'source_repo_name': source_repo.repo_name,
892 'source_refs_json': json.dumps(source_repo_data),
892 'source_refs_json': ext_json.str_json(source_repo_data),
893 893 'target_repo_name': default_target_repo.repo_name,
894 'target_refs_json': json.dumps(target_repo_data),
894 'target_refs_json': ext_json.str_json(target_repo_data),
895 895 }
896 896 c.default_source_ref = selected_source_ref
897 897
898 898 return self._get_template_context(c)
899 899
900 900 @LoginRequired()
901 901 @NotAnonymous()
902 902 @HasRepoPermissionAnyDecorator(
903 903 'repository.read', 'repository.write', 'repository.admin')
904 904 def pull_request_repo_refs(self):
905 905 self.load_default_context()
906 906 target_repo_name = self.request.matchdict['target_repo_name']
907 907 repo = Repository.get_by_repo_name(target_repo_name)
908 908 if not repo:
909 909 raise HTTPNotFound()
910 910
911 911 target_perm = HasRepoPermissionAny(
912 912 'repository.read', 'repository.write', 'repository.admin')(
913 913 target_repo_name)
914 914 if not target_perm:
915 915 raise HTTPNotFound()
916 916
917 917 return PullRequestModel().generate_repo_data(
918 918 repo, translator=self.request.translate)
919 919
920 920 @LoginRequired()
921 921 @NotAnonymous()
922 922 @HasRepoPermissionAnyDecorator(
923 923 'repository.read', 'repository.write', 'repository.admin')
924 924 def pullrequest_repo_targets(self):
925 925 _ = self.request.translate
926 926 filter_query = self.request.GET.get('query')
927 927
928 928 # get the parents
929 929 parent_target_repos = []
930 930 if self.db_repo.parent:
931 931 parents_query = Repository.query() \
932 932 .order_by(func.length(Repository.repo_name)) \
933 933 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
934 934
935 935 if filter_query:
936 936 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
937 937 parents_query = parents_query.filter(
938 938 Repository.repo_name.ilike(ilike_expression))
939 939 parents = parents_query.limit(20).all()
940 940
941 941 for parent in parents:
942 942 parent_vcs_obj = parent.scm_instance()
943 943 if parent_vcs_obj and not parent_vcs_obj.is_empty():
944 944 parent_target_repos.append(parent)
945 945
946 946 # get other forks, and repo itself
947 947 query = Repository.query() \
948 948 .order_by(func.length(Repository.repo_name)) \
949 949 .filter(
950 950 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
951 951 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
952 952 ) \
953 953 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
954 954
955 955 if filter_query:
956 956 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
957 957 query = query.filter(Repository.repo_name.ilike(ilike_expression))
958 958
959 959 limit = max(20 - len(parent_target_repos), 5) # not less then 5
960 960 target_repos = query.limit(limit).all()
961 961
962 962 all_target_repos = target_repos + parent_target_repos
963 963
964 964 repos = []
965 965 # This checks permissions to the repositories
966 966 for obj in ScmModel().get_repos(all_target_repos):
967 967 repos.append({
968 968 'id': obj['name'],
969 969 'text': obj['name'],
970 970 'type': 'repo',
971 971 'repo_id': obj['dbrepo']['repo_id'],
972 972 'repo_type': obj['dbrepo']['repo_type'],
973 973 'private': obj['dbrepo']['private'],
974 974
975 975 })
976 976
977 977 data = {
978 978 'more': False,
979 979 'results': [{
980 980 'text': _('Repositories'),
981 981 'children': repos
982 982 }] if repos else []
983 983 }
984 984 return data
985 985
986 986 @classmethod
987 987 def get_comment_ids(cls, post_data):
988 988 return filter(lambda e: e > 0, map(safe_int, aslist(post_data.get('comments'), ',')))
989 989
990 990 @LoginRequired()
991 991 @NotAnonymous()
992 992 @HasRepoPermissionAnyDecorator(
993 993 'repository.read', 'repository.write', 'repository.admin')
994 994 def pullrequest_comments(self):
995 995 self.load_default_context()
996 996
997 997 pull_request = PullRequest.get_or_404(
998 998 self.request.matchdict['pull_request_id'])
999 999 pull_request_id = pull_request.pull_request_id
1000 1000 version = self.request.GET.get('version')
1001 1001
1002 1002 _render = self.request.get_partial_renderer(
1003 1003 'rhodecode:templates/base/sidebar.mako')
1004 1004 c = _render.get_call_context()
1005 1005
1006 1006 (pull_request_latest,
1007 1007 pull_request_at_ver,
1008 1008 pull_request_display_obj,
1009 1009 at_version) = PullRequestModel().get_pr_version(
1010 1010 pull_request_id, version=version)
1011 1011 versions = pull_request_display_obj.versions()
1012 1012 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1013 1013 c.versions = versions + [latest_ver]
1014 1014
1015 1015 c.at_version = at_version
1016 1016 c.at_version_num = (at_version
1017 1017 if at_version and at_version != PullRequest.LATEST_VER
1018 1018 else None)
1019 1019
1020 1020 self.register_comments_vars(c, pull_request_latest, versions, include_drafts=False)
1021 1021 all_comments = c.inline_comments_flat + c.comments
1022 1022
1023 1023 existing_ids = self.get_comment_ids(self.request.POST)
1024 1024 return _render('comments_table', all_comments, len(all_comments),
1025 1025 existing_ids=existing_ids)
1026 1026
1027 1027 @LoginRequired()
1028 1028 @NotAnonymous()
1029 1029 @HasRepoPermissionAnyDecorator(
1030 1030 'repository.read', 'repository.write', 'repository.admin')
1031 1031 def pullrequest_todos(self):
1032 1032 self.load_default_context()
1033 1033
1034 1034 pull_request = PullRequest.get_or_404(
1035 1035 self.request.matchdict['pull_request_id'])
1036 1036 pull_request_id = pull_request.pull_request_id
1037 1037 version = self.request.GET.get('version')
1038 1038
1039 1039 _render = self.request.get_partial_renderer(
1040 1040 'rhodecode:templates/base/sidebar.mako')
1041 1041 c = _render.get_call_context()
1042 1042 (pull_request_latest,
1043 1043 pull_request_at_ver,
1044 1044 pull_request_display_obj,
1045 1045 at_version) = PullRequestModel().get_pr_version(
1046 1046 pull_request_id, version=version)
1047 1047 versions = pull_request_display_obj.versions()
1048 1048 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1049 1049 c.versions = versions + [latest_ver]
1050 1050
1051 1051 c.at_version = at_version
1052 1052 c.at_version_num = (at_version
1053 1053 if at_version and at_version != PullRequest.LATEST_VER
1054 1054 else None)
1055 1055
1056 1056 c.unresolved_comments = CommentsModel() \
1057 1057 .get_pull_request_unresolved_todos(pull_request, include_drafts=False)
1058 1058 c.resolved_comments = CommentsModel() \
1059 1059 .get_pull_request_resolved_todos(pull_request, include_drafts=False)
1060 1060
1061 1061 all_comments = c.unresolved_comments + c.resolved_comments
1062 1062 existing_ids = self.get_comment_ids(self.request.POST)
1063 1063 return _render('comments_table', all_comments, len(c.unresolved_comments),
1064 1064 todo_comments=True, existing_ids=existing_ids)
1065 1065
1066 1066 @LoginRequired()
1067 1067 @NotAnonymous()
1068 1068 @HasRepoPermissionAnyDecorator(
1069 1069 'repository.read', 'repository.write', 'repository.admin')
1070 1070 def pullrequest_drafts(self):
1071 1071 self.load_default_context()
1072 1072
1073 1073 pull_request = PullRequest.get_or_404(
1074 1074 self.request.matchdict['pull_request_id'])
1075 1075 pull_request_id = pull_request.pull_request_id
1076 1076 version = self.request.GET.get('version')
1077 1077
1078 1078 _render = self.request.get_partial_renderer(
1079 1079 'rhodecode:templates/base/sidebar.mako')
1080 1080 c = _render.get_call_context()
1081 1081
1082 1082 (pull_request_latest,
1083 1083 pull_request_at_ver,
1084 1084 pull_request_display_obj,
1085 1085 at_version) = PullRequestModel().get_pr_version(
1086 1086 pull_request_id, version=version)
1087 1087 versions = pull_request_display_obj.versions()
1088 1088 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1089 1089 c.versions = versions + [latest_ver]
1090 1090
1091 1091 c.at_version = at_version
1092 1092 c.at_version_num = (at_version
1093 1093 if at_version and at_version != PullRequest.LATEST_VER
1094 1094 else None)
1095 1095
1096 1096 c.draft_comments = CommentsModel() \
1097 1097 .get_pull_request_drafts(self._rhodecode_db_user.user_id, pull_request)
1098 1098
1099 1099 all_comments = c.draft_comments
1100 1100
1101 1101 existing_ids = self.get_comment_ids(self.request.POST)
1102 1102 return _render('comments_table', all_comments, len(all_comments),
1103 1103 existing_ids=existing_ids, draft_comments=True)
1104 1104
1105 1105 @LoginRequired()
1106 1106 @NotAnonymous()
1107 1107 @HasRepoPermissionAnyDecorator(
1108 1108 'repository.read', 'repository.write', 'repository.admin')
1109 1109 @CSRFRequired()
1110 1110 def pull_request_create(self):
1111 1111 _ = self.request.translate
1112 1112 self.assure_not_empty_repo()
1113 1113 self.load_default_context()
1114 1114
1115 1115 controls = peppercorn.parse(self.request.POST.items())
1116 1116
1117 1117 try:
1118 1118 form = PullRequestForm(
1119 1119 self.request.translate, self.db_repo.repo_id)()
1120 1120 _form = form.to_python(controls)
1121 1121 except formencode.Invalid as errors:
1122 1122 if errors.error_dict.get('revisions'):
1123 1123 msg = 'Revisions: %s' % errors.error_dict['revisions']
1124 1124 elif errors.error_dict.get('pullrequest_title'):
1125 1125 msg = errors.error_dict.get('pullrequest_title')
1126 1126 else:
1127 1127 msg = _('Error creating pull request: {}').format(errors)
1128 1128 log.exception(msg)
1129 1129 h.flash(msg, 'error')
1130 1130
1131 1131 # would rather just go back to form ...
1132 1132 raise HTTPFound(
1133 1133 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1134 1134
1135 1135 source_repo = _form['source_repo']
1136 1136 source_ref = _form['source_ref']
1137 1137 target_repo = _form['target_repo']
1138 1138 target_ref = _form['target_ref']
1139 1139 commit_ids = _form['revisions'][::-1]
1140 1140 common_ancestor_id = _form['common_ancestor']
1141 1141
1142 1142 # find the ancestor for this pr
1143 1143 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
1144 1144 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
1145 1145
1146 1146 if not (source_db_repo or target_db_repo):
1147 1147 h.flash(_('source_repo or target repo not found'), category='error')
1148 1148 raise HTTPFound(
1149 1149 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1150 1150
1151 1151 # re-check permissions again here
1152 1152 # source_repo we must have read permissions
1153 1153
1154 1154 source_perm = HasRepoPermissionAny(
1155 1155 'repository.read', 'repository.write', 'repository.admin')(
1156 1156 source_db_repo.repo_name)
1157 1157 if not source_perm:
1158 1158 msg = _('Not Enough permissions to source repo `{}`.'.format(
1159 1159 source_db_repo.repo_name))
1160 1160 h.flash(msg, category='error')
1161 1161 # copy the args back to redirect
1162 1162 org_query = self.request.GET.mixed()
1163 1163 raise HTTPFound(
1164 1164 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1165 1165 _query=org_query))
1166 1166
1167 1167 # target repo we must have read permissions, and also later on
1168 1168 # we want to check branch permissions here
1169 1169 target_perm = HasRepoPermissionAny(
1170 1170 'repository.read', 'repository.write', 'repository.admin')(
1171 1171 target_db_repo.repo_name)
1172 1172 if not target_perm:
1173 1173 msg = _('Not Enough permissions to target repo `{}`.'.format(
1174 1174 target_db_repo.repo_name))
1175 1175 h.flash(msg, category='error')
1176 1176 # copy the args back to redirect
1177 1177 org_query = self.request.GET.mixed()
1178 1178 raise HTTPFound(
1179 1179 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1180 1180 _query=org_query))
1181 1181
1182 1182 source_scm = source_db_repo.scm_instance()
1183 1183 target_scm = target_db_repo.scm_instance()
1184 1184
1185 1185 source_ref_obj = unicode_to_reference(source_ref)
1186 1186 target_ref_obj = unicode_to_reference(target_ref)
1187 1187
1188 1188 source_commit = source_scm.get_commit(source_ref_obj.commit_id)
1189 1189 target_commit = target_scm.get_commit(target_ref_obj.commit_id)
1190 1190
1191 1191 ancestor = source_scm.get_common_ancestor(
1192 1192 source_commit.raw_id, target_commit.raw_id, target_scm)
1193 1193
1194 1194 # recalculate target ref based on ancestor
1195 1195 target_ref = ':'.join((target_ref_obj.type, target_ref_obj.name, ancestor))
1196 1196
1197 1197 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1198 1198 PullRequestModel().get_reviewer_functions()
1199 1199
1200 1200 # recalculate reviewers logic, to make sure we can validate this
1201 1201 reviewer_rules = get_default_reviewers_data(
1202 1202 self._rhodecode_db_user,
1203 1203 source_db_repo,
1204 1204 source_ref_obj,
1205 1205 target_db_repo,
1206 1206 target_ref_obj,
1207 1207 include_diff_info=False)
1208 1208
1209 1209 reviewers = validate_default_reviewers(_form['review_members'], reviewer_rules)
1210 1210 observers = validate_observers(_form['observer_members'], reviewer_rules)
1211 1211
1212 1212 pullrequest_title = _form['pullrequest_title']
1213 1213 title_source_ref = source_ref_obj.name
1214 1214 if not pullrequest_title:
1215 1215 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1216 1216 source=source_repo,
1217 1217 source_ref=title_source_ref,
1218 1218 target=target_repo
1219 1219 )
1220 1220
1221 1221 description = _form['pullrequest_desc']
1222 1222 description_renderer = _form['description_renderer']
1223 1223
1224 1224 try:
1225 1225 pull_request = PullRequestModel().create(
1226 1226 created_by=self._rhodecode_user.user_id,
1227 1227 source_repo=source_repo,
1228 1228 source_ref=source_ref,
1229 1229 target_repo=target_repo,
1230 1230 target_ref=target_ref,
1231 1231 revisions=commit_ids,
1232 1232 common_ancestor_id=common_ancestor_id,
1233 1233 reviewers=reviewers,
1234 1234 observers=observers,
1235 1235 title=pullrequest_title,
1236 1236 description=description,
1237 1237 description_renderer=description_renderer,
1238 1238 reviewer_data=reviewer_rules,
1239 1239 auth_user=self._rhodecode_user
1240 1240 )
1241 1241 Session().commit()
1242 1242
1243 1243 h.flash(_('Successfully opened new pull request'),
1244 1244 category='success')
1245 1245 except Exception:
1246 1246 msg = _('Error occurred during creation of this pull request.')
1247 1247 log.exception(msg)
1248 1248 h.flash(msg, category='error')
1249 1249
1250 1250 # copy the args back to redirect
1251 1251 org_query = self.request.GET.mixed()
1252 1252 raise HTTPFound(
1253 1253 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1254 1254 _query=org_query))
1255 1255
1256 1256 raise HTTPFound(
1257 1257 h.route_path('pullrequest_show', repo_name=target_repo,
1258 1258 pull_request_id=pull_request.pull_request_id))
1259 1259
1260 1260 @LoginRequired()
1261 1261 @NotAnonymous()
1262 1262 @HasRepoPermissionAnyDecorator(
1263 1263 'repository.read', 'repository.write', 'repository.admin')
1264 1264 @CSRFRequired()
1265 1265 def pull_request_update(self):
1266 1266 pull_request = PullRequest.get_or_404(
1267 1267 self.request.matchdict['pull_request_id'])
1268 1268 _ = self.request.translate
1269 1269
1270 1270 c = self.load_default_context()
1271 1271 redirect_url = None
1272 1272 # we do this check as first, because we want to know ASAP in the flow that
1273 1273 # pr is updating currently
1274 1274 is_state_changing = pull_request.is_state_changing()
1275 1275
1276 1276 if pull_request.is_closed():
1277 1277 log.debug('update: forbidden because pull request is closed')
1278 1278 msg = _(u'Cannot update closed pull requests.')
1279 1279 h.flash(msg, category='error')
1280 1280 return {'response': True,
1281 1281 'redirect_url': redirect_url}
1282 1282
1283 1283 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
1284 1284
1285 1285 # only owner or admin can update it
1286 1286 allowed_to_update = PullRequestModel().check_user_update(
1287 1287 pull_request, self._rhodecode_user)
1288 1288
1289 1289 if allowed_to_update:
1290 1290 controls = peppercorn.parse(self.request.POST.items())
1291 1291 force_refresh = str2bool(self.request.POST.get('force_refresh', 'false'))
1292 1292 do_update_commits = str2bool(self.request.POST.get('update_commits', 'false'))
1293 1293
1294 1294 if 'review_members' in controls:
1295 1295 self._update_reviewers(
1296 1296 c,
1297 1297 pull_request, controls['review_members'],
1298 1298 pull_request.reviewer_data,
1299 1299 PullRequestReviewers.ROLE_REVIEWER)
1300 1300 elif 'observer_members' in controls:
1301 1301 self._update_reviewers(
1302 1302 c,
1303 1303 pull_request, controls['observer_members'],
1304 1304 pull_request.reviewer_data,
1305 1305 PullRequestReviewers.ROLE_OBSERVER)
1306 1306 elif do_update_commits:
1307 1307 if is_state_changing:
1308 1308 log.debug('commits update: forbidden because pull request is in state %s',
1309 1309 pull_request.pull_request_state)
1310 1310 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1311 1311 u'Current state is: `{}`').format(
1312 1312 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1313 1313 h.flash(msg, category='error')
1314 1314 return {'response': True,
1315 1315 'redirect_url': redirect_url}
1316 1316
1317 1317 self._update_commits(c, pull_request)
1318 1318 if force_refresh:
1319 1319 redirect_url = h.route_path(
1320 1320 'pullrequest_show', repo_name=self.db_repo_name,
1321 1321 pull_request_id=pull_request.pull_request_id,
1322 1322 _query={"force_refresh": 1})
1323 1323 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1324 1324 self._edit_pull_request(pull_request)
1325 1325 else:
1326 1326 log.error('Unhandled update data.')
1327 1327 raise HTTPBadRequest()
1328 1328
1329 1329 return {'response': True,
1330 1330 'redirect_url': redirect_url}
1331 1331 raise HTTPForbidden()
1332 1332
1333 1333 def _edit_pull_request(self, pull_request):
1334 1334 """
1335 1335 Edit title and description
1336 1336 """
1337 1337 _ = self.request.translate
1338 1338
1339 1339 try:
1340 1340 PullRequestModel().edit(
1341 1341 pull_request,
1342 1342 self.request.POST.get('title'),
1343 1343 self.request.POST.get('description'),
1344 1344 self.request.POST.get('description_renderer'),
1345 1345 self._rhodecode_user)
1346 1346 except ValueError:
1347 1347 msg = _(u'Cannot update closed pull requests.')
1348 1348 h.flash(msg, category='error')
1349 1349 return
1350 1350 else:
1351 1351 Session().commit()
1352 1352
1353 1353 msg = _(u'Pull request title & description updated.')
1354 1354 h.flash(msg, category='success')
1355 1355 return
1356 1356
1357 1357 def _update_commits(self, c, pull_request):
1358 1358 _ = self.request.translate
1359 1359 log.debug('pull-request: running update commits actions')
1360 1360
1361 1361 @retry(exception=Exception, n_tries=3, delay=2)
1362 1362 def commits_update():
1363 1363 return PullRequestModel().update_commits(
1364 1364 pull_request, self._rhodecode_db_user)
1365 1365
1366 1366 with pull_request.set_state(PullRequest.STATE_UPDATING):
1367 1367 resp = commits_update() # retry x3
1368 1368
1369 1369 if resp.executed:
1370 1370
1371 1371 if resp.target_changed and resp.source_changed:
1372 1372 changed = 'target and source repositories'
1373 1373 elif resp.target_changed and not resp.source_changed:
1374 1374 changed = 'target repository'
1375 1375 elif not resp.target_changed and resp.source_changed:
1376 1376 changed = 'source repository'
1377 1377 else:
1378 1378 changed = 'nothing'
1379 1379
1380 1380 msg = _(u'Pull request updated to "{source_commit_id}" with '
1381 1381 u'{count_added} added, {count_removed} removed commits. '
1382 1382 u'Source of changes: {change_source}.')
1383 1383 msg = msg.format(
1384 1384 source_commit_id=pull_request.source_ref_parts.commit_id,
1385 1385 count_added=len(resp.changes.added),
1386 1386 count_removed=len(resp.changes.removed),
1387 1387 change_source=changed)
1388 1388 h.flash(msg, category='success')
1389 1389 channelstream.pr_update_channelstream_push(
1390 1390 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1391 1391 else:
1392 1392 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1393 1393 warning_reasons = [
1394 1394 UpdateFailureReason.NO_CHANGE,
1395 1395 UpdateFailureReason.WRONG_REF_TYPE,
1396 1396 ]
1397 1397 category = 'warning' if resp.reason in warning_reasons else 'error'
1398 1398 h.flash(msg, category=category)
1399 1399
1400 1400 def _update_reviewers(self, c, pull_request, review_members, reviewer_rules, role):
1401 1401 _ = self.request.translate
1402 1402
1403 1403 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1404 1404 PullRequestModel().get_reviewer_functions()
1405 1405
1406 1406 if role == PullRequestReviewers.ROLE_REVIEWER:
1407 1407 try:
1408 1408 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1409 1409 except ValueError as e:
1410 1410 log.error('Reviewers Validation: {}'.format(e))
1411 1411 h.flash(e, category='error')
1412 1412 return
1413 1413
1414 1414 old_calculated_status = pull_request.calculated_review_status()
1415 1415 PullRequestModel().update_reviewers(
1416 1416 pull_request, reviewers, self._rhodecode_db_user)
1417 1417
1418 1418 Session().commit()
1419 1419
1420 1420 msg = _('Pull request reviewers updated.')
1421 1421 h.flash(msg, category='success')
1422 1422 channelstream.pr_update_channelstream_push(
1423 1423 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1424 1424
1425 1425 # trigger status changed if change in reviewers changes the status
1426 1426 calculated_status = pull_request.calculated_review_status()
1427 1427 if old_calculated_status != calculated_status:
1428 1428 PullRequestModel().trigger_pull_request_hook(
1429 1429 pull_request, self._rhodecode_user, 'review_status_change',
1430 1430 data={'status': calculated_status})
1431 1431
1432 1432 elif role == PullRequestReviewers.ROLE_OBSERVER:
1433 1433 try:
1434 1434 observers = validate_observers(review_members, reviewer_rules)
1435 1435 except ValueError as e:
1436 1436 log.error('Observers Validation: {}'.format(e))
1437 1437 h.flash(e, category='error')
1438 1438 return
1439 1439
1440 1440 PullRequestModel().update_observers(
1441 1441 pull_request, observers, self._rhodecode_db_user)
1442 1442
1443 1443 Session().commit()
1444 1444 msg = _('Pull request observers updated.')
1445 1445 h.flash(msg, category='success')
1446 1446 channelstream.pr_update_channelstream_push(
1447 1447 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1448 1448
1449 1449 @LoginRequired()
1450 1450 @NotAnonymous()
1451 1451 @HasRepoPermissionAnyDecorator(
1452 1452 'repository.read', 'repository.write', 'repository.admin')
1453 1453 @CSRFRequired()
1454 1454 def pull_request_merge(self):
1455 1455 """
1456 1456 Merge will perform a server-side merge of the specified
1457 1457 pull request, if the pull request is approved and mergeable.
1458 1458 After successful merging, the pull request is automatically
1459 1459 closed, with a relevant comment.
1460 1460 """
1461 1461 pull_request = PullRequest.get_or_404(
1462 1462 self.request.matchdict['pull_request_id'])
1463 1463 _ = self.request.translate
1464 1464
1465 1465 if pull_request.is_state_changing():
1466 1466 log.debug('show: forbidden because pull request is in state %s',
1467 1467 pull_request.pull_request_state)
1468 1468 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1469 1469 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1470 1470 pull_request.pull_request_state)
1471 1471 h.flash(msg, category='error')
1472 1472 raise HTTPFound(
1473 1473 h.route_path('pullrequest_show',
1474 1474 repo_name=pull_request.target_repo.repo_name,
1475 1475 pull_request_id=pull_request.pull_request_id))
1476 1476
1477 1477 self.load_default_context()
1478 1478
1479 1479 with pull_request.set_state(PullRequest.STATE_UPDATING):
1480 1480 check = MergeCheck.validate(
1481 1481 pull_request, auth_user=self._rhodecode_user,
1482 1482 translator=self.request.translate)
1483 1483 merge_possible = not check.failed
1484 1484
1485 1485 for err_type, error_msg in check.errors:
1486 1486 h.flash(error_msg, category=err_type)
1487 1487
1488 1488 if merge_possible:
1489 1489 log.debug("Pre-conditions checked, trying to merge.")
1490 1490 extras = vcs_operation_context(
1491 1491 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1492 1492 username=self._rhodecode_db_user.username, action='push',
1493 1493 scm=pull_request.target_repo.repo_type)
1494 1494 with pull_request.set_state(PullRequest.STATE_UPDATING):
1495 1495 self._merge_pull_request(
1496 1496 pull_request, self._rhodecode_db_user, extras)
1497 1497 else:
1498 1498 log.debug("Pre-conditions failed, NOT merging.")
1499 1499
1500 1500 raise HTTPFound(
1501 1501 h.route_path('pullrequest_show',
1502 1502 repo_name=pull_request.target_repo.repo_name,
1503 1503 pull_request_id=pull_request.pull_request_id))
1504 1504
1505 1505 def _merge_pull_request(self, pull_request, user, extras):
1506 1506 _ = self.request.translate
1507 1507 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1508 1508
1509 1509 if merge_resp.executed:
1510 1510 log.debug("The merge was successful, closing the pull request.")
1511 1511 PullRequestModel().close_pull_request(
1512 1512 pull_request.pull_request_id, user)
1513 1513 Session().commit()
1514 1514 msg = _('Pull request was successfully merged and closed.')
1515 1515 h.flash(msg, category='success')
1516 1516 else:
1517 1517 log.debug(
1518 1518 "The merge was not successful. Merge response: %s", merge_resp)
1519 1519 msg = merge_resp.merge_status_message
1520 1520 h.flash(msg, category='error')
1521 1521
1522 1522 @LoginRequired()
1523 1523 @NotAnonymous()
1524 1524 @HasRepoPermissionAnyDecorator(
1525 1525 'repository.read', 'repository.write', 'repository.admin')
1526 1526 @CSRFRequired()
1527 1527 def pull_request_delete(self):
1528 1528 _ = self.request.translate
1529 1529
1530 1530 pull_request = PullRequest.get_or_404(
1531 1531 self.request.matchdict['pull_request_id'])
1532 1532 self.load_default_context()
1533 1533
1534 1534 pr_closed = pull_request.is_closed()
1535 1535 allowed_to_delete = PullRequestModel().check_user_delete(
1536 1536 pull_request, self._rhodecode_user) and not pr_closed
1537 1537
1538 1538 # only owner can delete it !
1539 1539 if allowed_to_delete:
1540 1540 PullRequestModel().delete(pull_request, self._rhodecode_user)
1541 1541 Session().commit()
1542 1542 h.flash(_('Successfully deleted pull request'),
1543 1543 category='success')
1544 1544 raise HTTPFound(h.route_path('pullrequest_show_all',
1545 1545 repo_name=self.db_repo_name))
1546 1546
1547 1547 log.warning('user %s tried to delete pull request without access',
1548 1548 self._rhodecode_user)
1549 1549 raise HTTPNotFound()
1550 1550
1551 1551 def _pull_request_comments_create(self, pull_request, comments):
1552 1552 _ = self.request.translate
1553 1553 data = {}
1554 1554 if not comments:
1555 1555 return
1556 1556 pull_request_id = pull_request.pull_request_id
1557 1557
1558 1558 all_drafts = len([x for x in comments if str2bool(x['is_draft'])]) == len(comments)
1559 1559
1560 1560 for entry in comments:
1561 1561 c = self.load_default_context()
1562 1562 comment_type = entry['comment_type']
1563 1563 text = entry['text']
1564 1564 status = entry['status']
1565 1565 is_draft = str2bool(entry['is_draft'])
1566 1566 resolves_comment_id = entry['resolves_comment_id']
1567 1567 close_pull_request = entry['close_pull_request']
1568 1568 f_path = entry['f_path']
1569 1569 line_no = entry['line']
1570 1570 target_elem_id = 'file-{}'.format(h.safeid(h.safe_unicode(f_path)))
1571 1571
1572 1572 # the logic here should work like following, if we submit close
1573 1573 # pr comment, use `close_pull_request_with_comment` function
1574 1574 # else handle regular comment logic
1575 1575
1576 1576 if close_pull_request:
1577 1577 # only owner or admin or person with write permissions
1578 1578 allowed_to_close = PullRequestModel().check_user_update(
1579 1579 pull_request, self._rhodecode_user)
1580 1580 if not allowed_to_close:
1581 1581 log.debug('comment: forbidden because not allowed to close '
1582 1582 'pull request %s', pull_request_id)
1583 1583 raise HTTPForbidden()
1584 1584
1585 1585 # This also triggers `review_status_change`
1586 1586 comment, status = PullRequestModel().close_pull_request_with_comment(
1587 1587 pull_request, self._rhodecode_user, self.db_repo, message=text,
1588 1588 auth_user=self._rhodecode_user)
1589 1589 Session().flush()
1590 1590 is_inline = comment.is_inline
1591 1591
1592 1592 PullRequestModel().trigger_pull_request_hook(
1593 1593 pull_request, self._rhodecode_user, 'comment',
1594 1594 data={'comment': comment})
1595 1595
1596 1596 else:
1597 1597 # regular comment case, could be inline, or one with status.
1598 1598 # for that one we check also permissions
1599 1599 # Additionally ENSURE if somehow draft is sent we're then unable to change status
1600 1600 allowed_to_change_status = PullRequestModel().check_user_change_status(
1601 1601 pull_request, self._rhodecode_user) and not is_draft
1602 1602
1603 1603 if status and allowed_to_change_status:
1604 1604 message = (_('Status change %(transition_icon)s %(status)s')
1605 1605 % {'transition_icon': '>',
1606 1606 'status': ChangesetStatus.get_status_lbl(status)})
1607 1607 text = text or message
1608 1608
1609 1609 comment = CommentsModel().create(
1610 1610 text=text,
1611 1611 repo=self.db_repo.repo_id,
1612 1612 user=self._rhodecode_user.user_id,
1613 1613 pull_request=pull_request,
1614 1614 f_path=f_path,
1615 1615 line_no=line_no,
1616 1616 status_change=(ChangesetStatus.get_status_lbl(status)
1617 1617 if status and allowed_to_change_status else None),
1618 1618 status_change_type=(status
1619 1619 if status and allowed_to_change_status else None),
1620 1620 comment_type=comment_type,
1621 1621 is_draft=is_draft,
1622 1622 resolves_comment_id=resolves_comment_id,
1623 1623 auth_user=self._rhodecode_user,
1624 1624 send_email=not is_draft, # skip notification for draft comments
1625 1625 )
1626 1626 is_inline = comment.is_inline
1627 1627
1628 1628 if allowed_to_change_status:
1629 1629 # calculate old status before we change it
1630 1630 old_calculated_status = pull_request.calculated_review_status()
1631 1631
1632 1632 # get status if set !
1633 1633 if status:
1634 1634 ChangesetStatusModel().set_status(
1635 1635 self.db_repo.repo_id,
1636 1636 status,
1637 1637 self._rhodecode_user.user_id,
1638 1638 comment,
1639 1639 pull_request=pull_request
1640 1640 )
1641 1641
1642 1642 Session().flush()
1643 1643 # this is somehow required to get access to some relationship
1644 1644 # loaded on comment
1645 1645 Session().refresh(comment)
1646 1646
1647 1647 # skip notifications for drafts
1648 1648 if not is_draft:
1649 1649 PullRequestModel().trigger_pull_request_hook(
1650 1650 pull_request, self._rhodecode_user, 'comment',
1651 1651 data={'comment': comment})
1652 1652
1653 1653 # we now calculate the status of pull request, and based on that
1654 1654 # calculation we set the commits status
1655 1655 calculated_status = pull_request.calculated_review_status()
1656 1656 if old_calculated_status != calculated_status:
1657 1657 PullRequestModel().trigger_pull_request_hook(
1658 1658 pull_request, self._rhodecode_user, 'review_status_change',
1659 1659 data={'status': calculated_status})
1660 1660
1661 1661 comment_id = comment.comment_id
1662 1662 data[comment_id] = {
1663 1663 'target_id': target_elem_id
1664 1664 }
1665 1665 Session().flush()
1666 1666
1667 1667 c.co = comment
1668 1668 c.at_version_num = None
1669 1669 c.is_new = True
1670 1670 rendered_comment = render(
1671 1671 'rhodecode:templates/changeset/changeset_comment_block.mako',
1672 1672 self._get_template_context(c), self.request)
1673 1673
1674 1674 data[comment_id].update(comment.get_dict())
1675 1675 data[comment_id].update({'rendered_text': rendered_comment})
1676 1676
1677 1677 Session().commit()
1678 1678
1679 1679 # skip channelstream for draft comments
1680 1680 if not all_drafts:
1681 1681 comment_broadcast_channel = channelstream.comment_channel(
1682 1682 self.db_repo_name, pull_request_obj=pull_request)
1683 1683
1684 1684 comment_data = data
1685 1685 posted_comment_type = 'inline' if is_inline else 'general'
1686 1686 if len(data) == 1:
1687 1687 msg = _('posted {} new {} comment').format(len(data), posted_comment_type)
1688 1688 else:
1689 1689 msg = _('posted {} new {} comments').format(len(data), posted_comment_type)
1690 1690
1691 1691 channelstream.comment_channelstream_push(
1692 1692 self.request, comment_broadcast_channel, self._rhodecode_user, msg,
1693 1693 comment_data=comment_data)
1694 1694
1695 1695 return data
1696 1696
1697 1697 @LoginRequired()
1698 1698 @NotAnonymous()
1699 1699 @HasRepoPermissionAnyDecorator(
1700 1700 'repository.read', 'repository.write', 'repository.admin')
1701 1701 @CSRFRequired()
1702 1702 def pull_request_comment_create(self):
1703 1703 _ = self.request.translate
1704 1704
1705 1705 pull_request = PullRequest.get_or_404(self.request.matchdict['pull_request_id'])
1706 1706
1707 1707 if pull_request.is_closed():
1708 1708 log.debug('comment: forbidden because pull request is closed')
1709 1709 raise HTTPForbidden()
1710 1710
1711 1711 allowed_to_comment = PullRequestModel().check_user_comment(
1712 1712 pull_request, self._rhodecode_user)
1713 1713 if not allowed_to_comment:
1714 1714 log.debug('comment: forbidden because pull request is from forbidden repo')
1715 1715 raise HTTPForbidden()
1716 1716
1717 1717 comment_data = {
1718 1718 'comment_type': self.request.POST.get('comment_type'),
1719 1719 'text': self.request.POST.get('text'),
1720 1720 'status': self.request.POST.get('changeset_status', None),
1721 1721 'is_draft': self.request.POST.get('draft'),
1722 1722 'resolves_comment_id': self.request.POST.get('resolves_comment_id', None),
1723 1723 'close_pull_request': self.request.POST.get('close_pull_request'),
1724 1724 'f_path': self.request.POST.get('f_path'),
1725 1725 'line': self.request.POST.get('line'),
1726 1726 }
1727 1727 data = self._pull_request_comments_create(pull_request, [comment_data])
1728 1728
1729 1729 return data
1730 1730
1731 1731 @LoginRequired()
1732 1732 @NotAnonymous()
1733 1733 @HasRepoPermissionAnyDecorator(
1734 1734 'repository.read', 'repository.write', 'repository.admin')
1735 1735 @CSRFRequired()
1736 1736 def pull_request_comment_delete(self):
1737 1737 pull_request = PullRequest.get_or_404(
1738 1738 self.request.matchdict['pull_request_id'])
1739 1739
1740 1740 comment = ChangesetComment.get_or_404(
1741 1741 self.request.matchdict['comment_id'])
1742 1742 comment_id = comment.comment_id
1743 1743
1744 1744 if comment.immutable:
1745 1745 # don't allow deleting comments that are immutable
1746 1746 raise HTTPForbidden()
1747 1747
1748 1748 if pull_request.is_closed():
1749 1749 log.debug('comment: forbidden because pull request is closed')
1750 1750 raise HTTPForbidden()
1751 1751
1752 1752 if not comment:
1753 1753 log.debug('Comment with id:%s not found, skipping', comment_id)
1754 1754 # comment already deleted in another call probably
1755 1755 return True
1756 1756
1757 1757 if comment.pull_request.is_closed():
1758 1758 # don't allow deleting comments on closed pull request
1759 1759 raise HTTPForbidden()
1760 1760
1761 1761 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1762 1762 super_admin = h.HasPermissionAny('hg.admin')()
1763 1763 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1764 1764 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1765 1765 comment_repo_admin = is_repo_admin and is_repo_comment
1766 1766
1767 1767 if comment.draft and not comment_owner:
1768 1768 # We never allow to delete draft comments for other than owners
1769 1769 raise HTTPNotFound()
1770 1770
1771 1771 if super_admin or comment_owner or comment_repo_admin:
1772 1772 old_calculated_status = comment.pull_request.calculated_review_status()
1773 1773 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1774 1774 Session().commit()
1775 1775 calculated_status = comment.pull_request.calculated_review_status()
1776 1776 if old_calculated_status != calculated_status:
1777 1777 PullRequestModel().trigger_pull_request_hook(
1778 1778 comment.pull_request, self._rhodecode_user, 'review_status_change',
1779 1779 data={'status': calculated_status})
1780 1780 return True
1781 1781 else:
1782 1782 log.warning('No permissions for user %s to delete comment_id: %s',
1783 1783 self._rhodecode_db_user, comment_id)
1784 1784 raise HTTPNotFound()
1785 1785
1786 1786 @LoginRequired()
1787 1787 @NotAnonymous()
1788 1788 @HasRepoPermissionAnyDecorator(
1789 1789 'repository.read', 'repository.write', 'repository.admin')
1790 1790 @CSRFRequired()
1791 1791 def pull_request_comment_edit(self):
1792 1792 self.load_default_context()
1793 1793
1794 1794 pull_request = PullRequest.get_or_404(
1795 1795 self.request.matchdict['pull_request_id']
1796 1796 )
1797 1797 comment = ChangesetComment.get_or_404(
1798 1798 self.request.matchdict['comment_id']
1799 1799 )
1800 1800 comment_id = comment.comment_id
1801 1801
1802 1802 if comment.immutable:
1803 1803 # don't allow deleting comments that are immutable
1804 1804 raise HTTPForbidden()
1805 1805
1806 1806 if pull_request.is_closed():
1807 1807 log.debug('comment: forbidden because pull request is closed')
1808 1808 raise HTTPForbidden()
1809 1809
1810 1810 if comment.pull_request.is_closed():
1811 1811 # don't allow deleting comments on closed pull request
1812 1812 raise HTTPForbidden()
1813 1813
1814 1814 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1815 1815 super_admin = h.HasPermissionAny('hg.admin')()
1816 1816 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1817 1817 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1818 1818 comment_repo_admin = is_repo_admin and is_repo_comment
1819 1819
1820 1820 if super_admin or comment_owner or comment_repo_admin:
1821 1821 text = self.request.POST.get('text')
1822 1822 version = self.request.POST.get('version')
1823 1823 if text == comment.text:
1824 1824 log.warning(
1825 1825 'Comment(PR): '
1826 1826 'Trying to create new version '
1827 1827 'with the same comment body {}'.format(
1828 1828 comment_id,
1829 1829 )
1830 1830 )
1831 1831 raise HTTPNotFound()
1832 1832
1833 1833 if version.isdigit():
1834 1834 version = int(version)
1835 1835 else:
1836 1836 log.warning(
1837 1837 'Comment(PR): Wrong version type {} {} '
1838 1838 'for comment {}'.format(
1839 1839 version,
1840 1840 type(version),
1841 1841 comment_id,
1842 1842 )
1843 1843 )
1844 1844 raise HTTPNotFound()
1845 1845
1846 1846 try:
1847 1847 comment_history = CommentsModel().edit(
1848 1848 comment_id=comment_id,
1849 1849 text=text,
1850 1850 auth_user=self._rhodecode_user,
1851 1851 version=version,
1852 1852 )
1853 1853 except CommentVersionMismatch:
1854 1854 raise HTTPConflict()
1855 1855
1856 1856 if not comment_history:
1857 1857 raise HTTPNotFound()
1858 1858
1859 1859 Session().commit()
1860 1860 if not comment.draft:
1861 1861 PullRequestModel().trigger_pull_request_hook(
1862 1862 pull_request, self._rhodecode_user, 'comment_edit',
1863 1863 data={'comment': comment})
1864 1864
1865 1865 return {
1866 1866 'comment_history_id': comment_history.comment_history_id,
1867 1867 'comment_id': comment.comment_id,
1868 1868 'comment_version': comment_history.version,
1869 1869 'comment_author_username': comment_history.author.username,
1870 1870 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
1871 1871 'comment_created_on': h.age_component(comment_history.created_on,
1872 1872 time_is_local=True),
1873 1873 }
1874 1874 else:
1875 1875 log.warning('No permissions for user %s to edit comment_id: %s',
1876 1876 self._rhodecode_db_user, comment_id)
1877 1877 raise HTTPNotFound()
@@ -1,44 +1,44 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22
23 23 from rhodecode.apps._base import BaseReferencesView
24 from rhodecode.lib.ext_json import json
24 from rhodecode.lib import ext_json
25 25 from rhodecode.lib.auth import (LoginRequired, HasRepoPermissionAnyDecorator)
26 26
27 27 log = logging.getLogger(__name__)
28 28
29 29
30 30 class RepoTagsView(BaseReferencesView):
31 31
32 32 @LoginRequired()
33 33 @HasRepoPermissionAnyDecorator(
34 34 'repository.read', 'repository.write', 'repository.admin')
35 35 def tags(self):
36 36 c = self.load_default_context()
37 37
38 38 ref_items = self.rhodecode_vcs_repo.tags.items()
39 39 data = self.load_refs_context(
40 40 ref_items=ref_items, partials_template='tags/tags_data.mako')
41 41
42 42 c.has_references = bool(data)
43 c.data = json.dumps(data)
43 c.data = ext_json.str_json(data)
44 44 return self._get_template_context(c)
@@ -1,40 +1,41 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 from rhodecode.lib.ext_json import json
21 from rhodecode.lib import ext_json
22 22
23 23
24 24 def pyramid_ext_json(info):
25 25 """
26 26 Custom json renderer for pyramid to use our ext_json lib
27 27 """
28 28 def _render(value, system):
29 29 request = system.get('request')
30 30 indent = None
31 31 if request is not None:
32 32 response = request.response
33 33 ct = response.content_type
34 34 if ct == response.default_content_type:
35 35 response.content_type = 'application/json'
36 36 indent = getattr(request, 'ext_json_indent', None)
37
38 return json.dumps(value, indent=indent)
37 if indent:
38 return ext_json.formatted_json(value)
39 return ext_json.json.dumps(value)
39 40
40 41 return _render
@@ -1,2155 +1,2165 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 Helper functions
23 23
24 24 Consists of functions to typically be used within templates, but also
25 25 available to Controllers. This module is available to both as 'h'.
26 26 """
27 27 import base64
28 28 import collections
29 29
30 30 import os
31 31 import random
32 32 import hashlib
33 33 from io import StringIO
34 34 import textwrap
35 35 import urllib.request, urllib.parse, urllib.error
36 36 import math
37 37 import logging
38 38 import re
39 39 import time
40 40 import string
41 41 import hashlib
42 42 import regex
43 43 from collections import OrderedDict
44 44
45 45 import pygments
46 46 import itertools
47 47 import fnmatch
48 48 import bleach
49 49
50 50 from datetime import datetime
51 51 from functools import partial
52 52 from pygments.formatters.html import HtmlFormatter
53 53 from pygments.lexers import (
54 54 get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype)
55 55
56 56 from pyramid.threadlocal import get_current_request
57 57 from tempita import looper
58 58 from webhelpers2.html import literal, HTML, escape
59 59 from webhelpers2.html._autolink import _auto_link_urls
60 60 from webhelpers2.html.tools import (
61 61 button_to, highlight, js_obfuscate, strip_links, strip_tags)
62 62
63 63 from webhelpers2.text import (
64 64 chop_at, collapse, convert_accented_entities,
65 65 convert_misc_entities, lchop, plural, rchop, remove_formatting,
66 66 replace_whitespace, urlify, truncate, wrap_paragraphs)
67 67 from webhelpers2.date import time_ago_in_words
68 68
69 69 from webhelpers2.html.tags import (
70 70 _input, NotGiven, _make_safe_id_component as safeid,
71 71 form as insecure_form,
72 72 auto_discovery_link, checkbox, end_form, file,
73 73 hidden, image, javascript_link, link_to, link_to_if, link_to_unless, ol,
74 74 select as raw_select, stylesheet_link, submit, text, password, textarea,
75 75 ul, radio, Options)
76 76
77 77 from webhelpers2.number import format_byte_size
78 78
79 79 from rhodecode.lib.action_parser import action_parser
80 80 from rhodecode.lib.pagination import Page, RepoPage, SqlPage
81 from rhodecode.lib import ext_json
81 82 from rhodecode.lib.ext_json import json
83 from rhodecode.lib.str_utils import safe_bytes
82 84 from rhodecode.lib.utils import repo_name_slug, get_custom_lexer
83 85 from rhodecode.lib.utils2 import (
84 86 str2bool, safe_unicode, safe_str,
85 87 get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime,
86 88 AttributeDict, safe_int, md5, md5_safe, get_host_info)
87 89 from rhodecode.lib.markup_renderer import MarkupRenderer, relative_links
88 90 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
89 91 from rhodecode.lib.vcs.backends.base import BaseChangeset, EmptyCommit
90 92 from rhodecode.lib.vcs.conf.settings import ARCHIVE_SPECS
91 93 from rhodecode.lib.index.search_utils import get_matching_line_offsets
92 94 from rhodecode.config.conf import DATE_FORMAT, DATETIME_FORMAT
93 95 from rhodecode.model.changeset_status import ChangesetStatusModel
94 96 from rhodecode.model.db import Permission, User, Repository, UserApiKeys, FileStore
95 97 from rhodecode.model.repo_group import RepoGroupModel
96 98 from rhodecode.model.settings import IssueTrackerSettingsModel
97 99
98 100
99 101 log = logging.getLogger(__name__)
100 102
101 103
102 104 DEFAULT_USER = User.DEFAULT_USER
103 105 DEFAULT_USER_EMAIL = User.DEFAULT_USER_EMAIL
104 106
105 107
106 108 def asset(path, ver=None, **kwargs):
107 109 """
108 110 Helper to generate a static asset file path for rhodecode assets
109 111
110 112 eg. h.asset('images/image.png', ver='3923')
111 113
112 114 :param path: path of asset
113 115 :param ver: optional version query param to append as ?ver=
114 116 """
115 117 request = get_current_request()
116 118 query = {}
117 119 query.update(kwargs)
118 120 if ver:
119 121 query = {'ver': ver}
120 122 return request.static_path(
121 123 'rhodecode:public/{}'.format(path), _query=query)
122 124
123 125
124 126 default_html_escape_table = {
125 127 ord('&'): u'&amp;',
126 128 ord('<'): u'&lt;',
127 129 ord('>'): u'&gt;',
128 130 ord('"'): u'&quot;',
129 131 ord("'"): u'&#39;',
130 132 }
131 133
132 134
133 135 def html_escape(text, html_escape_table=default_html_escape_table):
134 136 """Produce entities within text."""
135 137 return text.translate(html_escape_table)
136 138
137 139
140 def str_json(*args, **kwargs):
141 return ext_json.str_json(*args, **kwargs)
142
143
144 def formatted_str_json(*args, **kwargs):
145 return ext_json.formatted_str_json(*args, **kwargs)
146
147
138 148 def chop_at_smart(s, sub, inclusive=False, suffix_if_chopped=None):
139 149 """
140 150 Truncate string ``s`` at the first occurrence of ``sub``.
141 151
142 152 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
143 153 """
144 154 suffix_if_chopped = suffix_if_chopped or ''
145 155 pos = s.find(sub)
146 156 if pos == -1:
147 157 return s
148 158
149 159 if inclusive:
150 160 pos += len(sub)
151 161
152 162 chopped = s[:pos]
153 163 left = s[pos:].strip()
154 164
155 165 if left and suffix_if_chopped:
156 166 chopped += suffix_if_chopped
157 167
158 168 return chopped
159 169
160 170
161 171 def shorter(text, size=20, prefix=False):
162 172 postfix = '...'
163 173 if len(text) > size:
164 174 if prefix:
165 175 # shorten in front
166 176 return postfix + text[-(size - len(postfix)):]
167 177 else:
168 178 return text[:size - len(postfix)] + postfix
169 179 return text
170 180
171 181
172 182 def reset(name, value=None, id=NotGiven, type="reset", **attrs):
173 183 """
174 184 Reset button
175 185 """
176 186 return _input(type, name, value, id, attrs)
177 187
178 188
179 189 def select(name, selected_values, options, id=NotGiven, **attrs):
180 190
181 191 if isinstance(options, (list, tuple)):
182 192 options_iter = options
183 193 # Handle old value,label lists ... where value also can be value,label lists
184 194 options = Options()
185 195 for opt in options_iter:
186 196 if isinstance(opt, tuple) and len(opt) == 2:
187 197 value, label = opt
188 198 elif isinstance(opt, str):
189 199 value = label = opt
190 200 else:
191 201 raise ValueError('invalid select option type %r' % type(opt))
192 202
193 203 if isinstance(value, (list, tuple)):
194 204 option_group = options.add_optgroup(label)
195 205 for opt2 in value:
196 206 if isinstance(opt2, tuple) and len(opt2) == 2:
197 207 group_value, group_label = opt2
198 208 elif isinstance(opt2, str):
199 209 group_value = group_label = opt2
200 210 else:
201 211 raise ValueError('invalid select option type %r' % type(opt2))
202 212
203 213 option_group.add_option(group_label, group_value)
204 214 else:
205 215 options.add_option(label, value)
206 216
207 217 return raw_select(name, selected_values, options, id=id, **attrs)
208 218
209 219
210 220 def branding(name, length=40):
211 221 return truncate(name, length, indicator="")
212 222
213 223
214 224 def FID(raw_id, path):
215 225 """
216 226 Creates a unique ID for filenode based on it's hash of path and commit
217 227 it's safe to use in urls
218 228
219 229 :param raw_id:
220 230 :param path:
221 231 """
222 232
223 233 return 'c-%s-%s' % (short_id(raw_id), md5_safe(path)[:12])
224 234
225 235
226 236 class _GetError(object):
227 237 """Get error from form_errors, and represent it as span wrapped error
228 238 message
229 239
230 240 :param field_name: field to fetch errors for
231 241 :param form_errors: form errors dict
232 242 """
233 243
234 244 def __call__(self, field_name, form_errors):
235 245 tmpl = """<span class="error_msg">%s</span>"""
236 246 if form_errors and field_name in form_errors:
237 247 return literal(tmpl % form_errors.get(field_name))
238 248
239 249
240 250 get_error = _GetError()
241 251
242 252
243 253 class _ToolTip(object):
244 254
245 255 def __call__(self, tooltip_title, trim_at=50):
246 256 """
247 257 Special function just to wrap our text into nice formatted
248 258 autowrapped text
249 259
250 260 :param tooltip_title:
251 261 """
252 262 tooltip_title = escape(tooltip_title)
253 263 tooltip_title = tooltip_title.replace('<', '&lt;').replace('>', '&gt;')
254 264 return tooltip_title
255 265
256 266
257 267 tooltip = _ToolTip()
258 268
259 269 files_icon = u'<i class="file-breadcrumb-copy tooltip icon-clipboard clipboard-action" data-clipboard-text="{}" title="Copy file path"></i>'
260 270
261 271
262 272 def files_breadcrumbs(repo_name, repo_type, commit_id, file_path, landing_ref_name=None, at_ref=None,
263 273 limit_items=False, linkify_last_item=False, hide_last_item=False,
264 274 copy_path_icon=True):
265 275 if isinstance(file_path, str):
266 276 file_path = safe_unicode(file_path)
267 277
268 278 if at_ref:
269 279 route_qry = {'at': at_ref}
270 280 default_landing_ref = at_ref or landing_ref_name or commit_id
271 281 else:
272 282 route_qry = None
273 283 default_landing_ref = commit_id
274 284
275 285 # first segment is a `HOME` link to repo files root location
276 286 root_name = literal(u'<i class="icon-home"></i>')
277 287
278 288 url_segments = [
279 289 link_to(
280 290 root_name,
281 291 repo_files_by_ref_url(
282 292 repo_name,
283 293 repo_type,
284 294 f_path=None, # None here is a special case for SVN repos,
285 295 # that won't prefix with a ref
286 296 ref_name=default_landing_ref,
287 297 commit_id=commit_id,
288 298 query=route_qry
289 299 )
290 300 )]
291 301
292 302 path_segments = file_path.split('/')
293 303 last_cnt = len(path_segments) - 1
294 304 for cnt, segment in enumerate(path_segments):
295 305 if not segment:
296 306 continue
297 307 segment_html = escape(segment)
298 308
299 309 last_item = cnt == last_cnt
300 310
301 311 if last_item and hide_last_item:
302 312 # iterate over and hide last element
303 313 continue
304 314
305 315 if last_item and linkify_last_item is False:
306 316 # plain version
307 317 url_segments.append(segment_html)
308 318 else:
309 319 url_segments.append(
310 320 link_to(
311 321 segment_html,
312 322 repo_files_by_ref_url(
313 323 repo_name,
314 324 repo_type,
315 325 f_path='/'.join(path_segments[:cnt + 1]),
316 326 ref_name=default_landing_ref,
317 327 commit_id=commit_id,
318 328 query=route_qry
319 329 ),
320 330 ))
321 331
322 332 limited_url_segments = url_segments[:1] + ['...'] + url_segments[-5:]
323 333 if limit_items and len(limited_url_segments) < len(url_segments):
324 334 url_segments = limited_url_segments
325 335
326 336 full_path = file_path
327 337 if copy_path_icon:
328 338 icon = files_icon.format(escape(full_path))
329 339 else:
330 340 icon = ''
331 341
332 342 if file_path == '':
333 343 return root_name
334 344 else:
335 345 return literal(' / '.join(url_segments) + icon)
336 346
337 347
338 348 def files_url_data(request):
339 349 import urllib.request, urllib.parse, urllib.error
340 350 matchdict = request.matchdict
341 351
342 352 if 'f_path' not in matchdict:
343 353 matchdict['f_path'] = ''
344 354 else:
345 355 matchdict['f_path'] = urllib.parse.quote(safe_str(matchdict['f_path']))
346 356 if 'commit_id' not in matchdict:
347 357 matchdict['commit_id'] = 'tip'
348 358
349 return json.dumps(matchdict)
359 return ext_json.str_json(matchdict)
350 360
351 361
352 362 def repo_files_by_ref_url(db_repo_name, db_repo_type, f_path, ref_name, commit_id, query=None, ):
353 363 _is_svn = is_svn(db_repo_type)
354 364 final_f_path = f_path
355 365
356 366 if _is_svn:
357 367 """
358 368 For SVN the ref_name cannot be used as a commit_id, it needs to be prefixed with
359 369 actually commit_id followed by the ref_name. This should be done only in case
360 370 This is a initial landing url, without additional paths.
361 371
362 372 like: /1000/tags/1.0.0/?at=tags/1.0.0
363 373 """
364 374
365 375 if ref_name and ref_name != 'tip':
366 376 # NOTE(marcink): for svn the ref_name is actually the stored path, so we prefix it
367 377 # for SVN we only do this magic prefix if it's root, .eg landing revision
368 378 # of files link. If we are in the tree we don't need this since we traverse the url
369 379 # that has everything stored
370 380 if f_path in ['', '/']:
371 381 final_f_path = '/'.join([ref_name, f_path])
372 382
373 383 # SVN always needs a commit_id explicitly, without a named REF
374 384 default_commit_id = commit_id
375 385 else:
376 386 """
377 387 For git and mercurial we construct a new URL using the names instead of commit_id
378 388 like: /master/some_path?at=master
379 389 """
380 390 # We currently do not support branches with slashes
381 391 if '/' in ref_name:
382 392 default_commit_id = commit_id
383 393 else:
384 394 default_commit_id = ref_name
385 395
386 396 # sometimes we pass f_path as None, to indicate explicit no prefix,
387 397 # we translate it to string to not have None
388 398 final_f_path = final_f_path or ''
389 399
390 400 files_url = route_path(
391 401 'repo_files',
392 402 repo_name=db_repo_name,
393 403 commit_id=default_commit_id,
394 404 f_path=final_f_path,
395 405 _query=query
396 406 )
397 407 return files_url
398 408
399 409
400 410 def code_highlight(code, lexer, formatter, use_hl_filter=False):
401 411 """
402 412 Lex ``code`` with ``lexer`` and format it with the formatter ``formatter``.
403 413
404 414 If ``outfile`` is given and a valid file object (an object
405 415 with a ``write`` method), the result will be written to it, otherwise
406 416 it is returned as a string.
407 417 """
408 418 if use_hl_filter:
409 419 # add HL filter
410 420 from rhodecode.lib.index import search_utils
411 421 lexer.add_filter(search_utils.ElasticSearchHLFilter())
412 422 return pygments.format(pygments.lex(code, lexer), formatter)
413 423
414 424
415 425 class CodeHtmlFormatter(HtmlFormatter):
416 426 """
417 427 My code Html Formatter for source codes
418 428 """
419 429
420 430 def wrap(self, source, outfile):
421 431 return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
422 432
423 433 def _wrap_code(self, source):
424 434 for cnt, it in enumerate(source):
425 435 i, t = it
426 436 t = '<div id="L%s">%s</div>' % (cnt + 1, t)
427 437 yield i, t
428 438
429 439 def _wrap_tablelinenos(self, inner):
430 440 dummyoutfile = StringIO.StringIO()
431 441 lncount = 0
432 442 for t, line in inner:
433 443 if t:
434 444 lncount += 1
435 445 dummyoutfile.write(line)
436 446
437 447 fl = self.linenostart
438 448 mw = len(str(lncount + fl - 1))
439 449 sp = self.linenospecial
440 450 st = self.linenostep
441 451 la = self.lineanchors
442 452 aln = self.anchorlinenos
443 453 nocls = self.noclasses
444 454 if sp:
445 455 lines = []
446 456
447 457 for i in range(fl, fl + lncount):
448 458 if i % st == 0:
449 459 if i % sp == 0:
450 460 if aln:
451 461 lines.append('<a href="#%s%d" class="special">%*d</a>' %
452 462 (la, i, mw, i))
453 463 else:
454 464 lines.append('<span class="special">%*d</span>' % (mw, i))
455 465 else:
456 466 if aln:
457 467 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
458 468 else:
459 469 lines.append('%*d' % (mw, i))
460 470 else:
461 471 lines.append('')
462 472 ls = '\n'.join(lines)
463 473 else:
464 474 lines = []
465 475 for i in range(fl, fl + lncount):
466 476 if i % st == 0:
467 477 if aln:
468 478 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
469 479 else:
470 480 lines.append('%*d' % (mw, i))
471 481 else:
472 482 lines.append('')
473 483 ls = '\n'.join(lines)
474 484
475 485 # in case you wonder about the seemingly redundant <div> here: since the
476 486 # content in the other cell also is wrapped in a div, some browsers in
477 487 # some configurations seem to mess up the formatting...
478 488 if nocls:
479 489 yield 0, ('<table class="%stable">' % self.cssclass +
480 490 '<tr><td><div class="linenodiv" '
481 491 'style="background-color: #f0f0f0; padding-right: 10px">'
482 492 '<pre style="line-height: 125%">' +
483 493 ls + '</pre></div></td><td id="hlcode" class="code">')
484 494 else:
485 495 yield 0, ('<table class="%stable">' % self.cssclass +
486 496 '<tr><td class="linenos"><div class="linenodiv"><pre>' +
487 497 ls + '</pre></div></td><td id="hlcode" class="code">')
488 498 yield 0, dummyoutfile.getvalue()
489 499 yield 0, '</td></tr></table>'
490 500
491 501
492 502 class SearchContentCodeHtmlFormatter(CodeHtmlFormatter):
493 503 def __init__(self, **kw):
494 504 # only show these line numbers if set
495 505 self.only_lines = kw.pop('only_line_numbers', [])
496 506 self.query_terms = kw.pop('query_terms', [])
497 507 self.max_lines = kw.pop('max_lines', 5)
498 508 self.line_context = kw.pop('line_context', 3)
499 509 self.url = kw.pop('url', None)
500 510
501 511 super(CodeHtmlFormatter, self).__init__(**kw)
502 512
503 513 def _wrap_code(self, source):
504 514 for cnt, it in enumerate(source):
505 515 i, t = it
506 516 t = '<pre>%s</pre>' % t
507 517 yield i, t
508 518
509 519 def _wrap_tablelinenos(self, inner):
510 520 yield 0, '<table class="code-highlight %stable">' % self.cssclass
511 521
512 522 last_shown_line_number = 0
513 523 current_line_number = 1
514 524
515 525 for t, line in inner:
516 526 if not t:
517 527 yield t, line
518 528 continue
519 529
520 530 if current_line_number in self.only_lines:
521 531 if last_shown_line_number + 1 != current_line_number:
522 532 yield 0, '<tr>'
523 533 yield 0, '<td class="line">...</td>'
524 534 yield 0, '<td id="hlcode" class="code"></td>'
525 535 yield 0, '</tr>'
526 536
527 537 yield 0, '<tr>'
528 538 if self.url:
529 539 yield 0, '<td class="line"><a href="%s#L%i">%i</a></td>' % (
530 540 self.url, current_line_number, current_line_number)
531 541 else:
532 542 yield 0, '<td class="line"><a href="">%i</a></td>' % (
533 543 current_line_number)
534 544 yield 0, '<td id="hlcode" class="code">' + line + '</td>'
535 545 yield 0, '</tr>'
536 546
537 547 last_shown_line_number = current_line_number
538 548
539 549 current_line_number += 1
540 550
541 551 yield 0, '</table>'
542 552
543 553
544 554 def hsv_to_rgb(h, s, v):
545 555 """ Convert hsv color values to rgb """
546 556
547 557 if s == 0.0:
548 558 return v, v, v
549 559 i = int(h * 6.0) # XXX assume int() truncates!
550 560 f = (h * 6.0) - i
551 561 p = v * (1.0 - s)
552 562 q = v * (1.0 - s * f)
553 563 t = v * (1.0 - s * (1.0 - f))
554 564 i = i % 6
555 565 if i == 0:
556 566 return v, t, p
557 567 if i == 1:
558 568 return q, v, p
559 569 if i == 2:
560 570 return p, v, t
561 571 if i == 3:
562 572 return p, q, v
563 573 if i == 4:
564 574 return t, p, v
565 575 if i == 5:
566 576 return v, p, q
567 577
568 578
569 579 def unique_color_generator(n=10000, saturation=0.10, lightness=0.95):
570 580 """
571 581 Generator for getting n of evenly distributed colors using
572 582 hsv color and golden ratio. It always return same order of colors
573 583
574 584 :param n: number of colors to generate
575 585 :param saturation: saturation of returned colors
576 586 :param lightness: lightness of returned colors
577 587 :returns: RGB tuple
578 588 """
579 589
580 590 golden_ratio = 0.618033988749895
581 591 h = 0.22717784590367374
582 592
583 593 for _ in range(n):
584 594 h += golden_ratio
585 595 h %= 1
586 596 HSV_tuple = [h, saturation, lightness]
587 597 RGB_tuple = hsv_to_rgb(*HSV_tuple)
588 598 yield map(lambda x: str(int(x * 256)), RGB_tuple)
589 599
590 600
591 601 def color_hasher(n=10000, saturation=0.10, lightness=0.95):
592 602 """
593 603 Returns a function which when called with an argument returns a unique
594 604 color for that argument, eg.
595 605
596 606 :param n: number of colors to generate
597 607 :param saturation: saturation of returned colors
598 608 :param lightness: lightness of returned colors
599 609 :returns: css RGB string
600 610
601 611 >>> color_hash = color_hasher()
602 612 >>> color_hash('hello')
603 613 'rgb(34, 12, 59)'
604 614 >>> color_hash('hello')
605 615 'rgb(34, 12, 59)'
606 616 >>> color_hash('other')
607 617 'rgb(90, 224, 159)'
608 618 """
609 619
610 620 color_dict = {}
611 621 cgenerator = unique_color_generator(
612 622 saturation=saturation, lightness=lightness)
613 623
614 624 def get_color_string(thing):
615 625 if thing in color_dict:
616 626 col = color_dict[thing]
617 627 else:
618 628 col = color_dict[thing] = next(cgenerator)
619 629 return "rgb(%s)" % (', '.join(col))
620 630
621 631 return get_color_string
622 632
623 633
624 634 def get_lexer_safe(mimetype=None, filepath=None):
625 635 """
626 636 Tries to return a relevant pygments lexer using mimetype/filepath name,
627 637 defaulting to plain text if none could be found
628 638 """
629 639 lexer = None
630 640 try:
631 641 if mimetype:
632 642 lexer = get_lexer_for_mimetype(mimetype)
633 643 if not lexer:
634 644 lexer = get_lexer_for_filename(filepath)
635 645 except pygments.util.ClassNotFound:
636 646 pass
637 647
638 648 if not lexer:
639 649 lexer = get_lexer_by_name('text')
640 650
641 651 return lexer
642 652
643 653
644 654 def get_lexer_for_filenode(filenode):
645 655 lexer = get_custom_lexer(filenode.extension) or filenode.lexer
646 656 return lexer
647 657
648 658
649 659 def pygmentize(filenode, **kwargs):
650 660 """
651 661 pygmentize function using pygments
652 662
653 663 :param filenode:
654 664 """
655 665 lexer = get_lexer_for_filenode(filenode)
656 666 return literal(code_highlight(filenode.content, lexer,
657 667 CodeHtmlFormatter(**kwargs)))
658 668
659 669
660 670 def is_following_repo(repo_name, user_id):
661 671 from rhodecode.model.scm import ScmModel
662 672 return ScmModel().is_following_repo(repo_name, user_id)
663 673
664 674
665 675 class _Message(object):
666 676 """A message returned by ``Flash.pop_messages()``.
667 677
668 678 Converting the message to a string returns the message text. Instances
669 679 also have the following attributes:
670 680
671 681 * ``message``: the message text.
672 682 * ``category``: the category specified when the message was created.
673 683 """
674 684
675 685 def __init__(self, category, message, sub_data=None):
676 686 self.category = category
677 687 self.message = message
678 688 self.sub_data = sub_data or {}
679 689
680 690 def __str__(self):
681 691 return self.message
682 692
683 693 __unicode__ = __str__
684 694
685 695 def __html__(self):
686 696 return escape(safe_unicode(self.message))
687 697
688 698
689 699 class Flash(object):
690 700 # List of allowed categories. If None, allow any category.
691 701 categories = ["warning", "notice", "error", "success"]
692 702
693 703 # Default category if none is specified.
694 704 default_category = "notice"
695 705
696 706 def __init__(self, session_key="flash", categories=None,
697 707 default_category=None):
698 708 """
699 709 Instantiate a ``Flash`` object.
700 710
701 711 ``session_key`` is the key to save the messages under in the user's
702 712 session.
703 713
704 714 ``categories`` is an optional list which overrides the default list
705 715 of categories.
706 716
707 717 ``default_category`` overrides the default category used for messages
708 718 when none is specified.
709 719 """
710 720 self.session_key = session_key
711 721 if categories is not None:
712 722 self.categories = categories
713 723 if default_category is not None:
714 724 self.default_category = default_category
715 725 if self.categories and self.default_category not in self.categories:
716 726 raise ValueError(
717 727 "unrecognized default category %r" % (self.default_category,))
718 728
719 729 def pop_messages(self, session=None, request=None):
720 730 """
721 731 Return all accumulated messages and delete them from the session.
722 732
723 733 The return value is a list of ``Message`` objects.
724 734 """
725 735 messages = []
726 736
727 737 if not session:
728 738 if not request:
729 739 request = get_current_request()
730 740 session = request.session
731 741
732 742 # Pop the 'old' pylons flash messages. They are tuples of the form
733 743 # (category, message)
734 744 for cat, msg in session.pop(self.session_key, []):
735 745 messages.append(_Message(cat, msg))
736 746
737 747 # Pop the 'new' pyramid flash messages for each category as list
738 748 # of strings.
739 749 for cat in self.categories:
740 750 for msg in session.pop_flash(queue=cat):
741 751 sub_data = {}
742 752 if hasattr(msg, 'rsplit'):
743 753 flash_data = msg.rsplit('|DELIM|', 1)
744 754 org_message = flash_data[0]
745 755 if len(flash_data) > 1:
746 756 sub_data = json.loads(flash_data[1])
747 757 else:
748 758 org_message = msg
749 759
750 760 messages.append(_Message(cat, org_message, sub_data=sub_data))
751 761
752 762 # Map messages from the default queue to the 'notice' category.
753 763 for msg in session.pop_flash():
754 764 messages.append(_Message('notice', msg))
755 765
756 766 session.save()
757 767 return messages
758 768
759 769 def json_alerts(self, session=None, request=None):
760 770 payloads = []
761 771 messages = flash.pop_messages(session=session, request=request) or []
762 772 for message in messages:
763 773 payloads.append({
764 774 'message': {
765 775 'message': u'{}'.format(message.message),
766 776 'level': message.category,
767 777 'force': True,
768 778 'subdata': message.sub_data
769 779 }
770 780 })
771 return json.dumps(payloads)
781 return safe_str(json.dumps(payloads))
772 782
773 783 def __call__(self, message, category=None, ignore_duplicate=True,
774 784 session=None, request=None):
775 785
776 786 if not session:
777 787 if not request:
778 788 request = get_current_request()
779 789 session = request.session
780 790
781 791 session.flash(
782 792 message, queue=category, allow_duplicate=not ignore_duplicate)
783 793
784 794
785 795 flash = Flash()
786 796
787 797 #==============================================================================
788 798 # SCM FILTERS available via h.
789 799 #==============================================================================
790 800 from rhodecode.lib.vcs.utils import author_name, author_email
791 801 from rhodecode.lib.utils2 import age, age_from_seconds
792 802 from rhodecode.model.db import User, ChangesetStatus
793 803
794 804
795 805 email = author_email
796 806
797 807
798 808 def capitalize(raw_text):
799 809 return raw_text.capitalize()
800 810
801 811
802 812 def short_id(long_id):
803 813 return long_id[:12]
804 814
805 815
806 816 def hide_credentials(url):
807 817 from rhodecode.lib.utils2 import credentials_filter
808 818 return credentials_filter(url)
809 819
810 820
811 821 import pytz
812 822 import tzlocal
813 823 local_timezone = tzlocal.get_localzone()
814 824
815 825
816 826 def get_timezone(datetime_iso, time_is_local=False):
817 827 tzinfo = '+00:00'
818 828
819 829 # detect if we have a timezone info, otherwise, add it
820 830 if time_is_local and isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo:
821 831 force_timezone = os.environ.get('RC_TIMEZONE', '')
822 832 if force_timezone:
823 833 force_timezone = pytz.timezone(force_timezone)
824 834 timezone = force_timezone or local_timezone
825 835 offset = timezone.localize(datetime_iso).strftime('%z')
826 836 tzinfo = '{}:{}'.format(offset[:-2], offset[-2:])
827 837 return tzinfo
828 838
829 839
830 840 def age_component(datetime_iso, value=None, time_is_local=False, tooltip=True):
831 841 title = value or format_date(datetime_iso)
832 842 tzinfo = get_timezone(datetime_iso, time_is_local=time_is_local)
833 843
834 844 return literal(
835 845 '<time class="timeago {cls}" title="{tt_title}" datetime="{dt}{tzinfo}">{title}</time>'.format(
836 846 cls='tooltip' if tooltip else '',
837 847 tt_title=('{title}{tzinfo}'.format(title=title, tzinfo=tzinfo)) if tooltip else '',
838 848 title=title, dt=datetime_iso, tzinfo=tzinfo
839 849 ))
840 850
841 851
842 852 def _shorten_commit_id(commit_id, commit_len=None):
843 853 if commit_len is None:
844 854 request = get_current_request()
845 855 commit_len = request.call_context.visual.show_sha_length
846 856 return commit_id[:commit_len]
847 857
848 858
849 859 def show_id(commit, show_idx=None, commit_len=None):
850 860 """
851 861 Configurable function that shows ID
852 862 by default it's r123:fffeeefffeee
853 863
854 864 :param commit: commit instance
855 865 """
856 866 if show_idx is None:
857 867 request = get_current_request()
858 868 show_idx = request.call_context.visual.show_revision_number
859 869
860 870 raw_id = _shorten_commit_id(commit.raw_id, commit_len=commit_len)
861 871 if show_idx:
862 872 return 'r%s:%s' % (commit.idx, raw_id)
863 873 else:
864 874 return '%s' % (raw_id, )
865 875
866 876
867 877 def format_date(date):
868 878 """
869 879 use a standardized formatting for dates used in RhodeCode
870 880
871 881 :param date: date/datetime object
872 882 :return: formatted date
873 883 """
874 884
875 885 if date:
876 886 _fmt = "%a, %d %b %Y %H:%M:%S"
877 887 return safe_unicode(date.strftime(_fmt))
878 888
879 889 return u""
880 890
881 891
882 892 class _RepoChecker(object):
883 893
884 894 def __init__(self, backend_alias):
885 895 self._backend_alias = backend_alias
886 896
887 897 def __call__(self, repository):
888 898 if hasattr(repository, 'alias'):
889 899 _type = repository.alias
890 900 elif hasattr(repository, 'repo_type'):
891 901 _type = repository.repo_type
892 902 else:
893 903 _type = repository
894 904 return _type == self._backend_alias
895 905
896 906
897 907 is_git = _RepoChecker('git')
898 908 is_hg = _RepoChecker('hg')
899 909 is_svn = _RepoChecker('svn')
900 910
901 911
902 912 def get_repo_type_by_name(repo_name):
903 913 repo = Repository.get_by_repo_name(repo_name)
904 914 if repo:
905 915 return repo.repo_type
906 916
907 917
908 918 def is_svn_without_proxy(repository):
909 919 if is_svn(repository):
910 920 from rhodecode.model.settings import VcsSettingsModel
911 921 conf = VcsSettingsModel().get_ui_settings_as_config_obj()
912 922 return not str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
913 923 return False
914 924
915 925
916 926 def discover_user(author):
917 927 """
918 928 Tries to discover RhodeCode User based on the author string. Author string
919 929 is typically `FirstName LastName <email@address.com>`
920 930 """
921 931
922 932 # if author is already an instance use it for extraction
923 933 if isinstance(author, User):
924 934 return author
925 935
926 936 # Valid email in the attribute passed, see if they're in the system
927 937 _email = author_email(author)
928 938 if _email != '':
929 939 user = User.get_by_email(_email, case_insensitive=True, cache=True)
930 940 if user is not None:
931 941 return user
932 942
933 943 # Maybe it's a username, we try to extract it and fetch by username ?
934 944 _author = author_name(author)
935 945 user = User.get_by_username(_author, case_insensitive=True, cache=True)
936 946 if user is not None:
937 947 return user
938 948
939 949 return None
940 950
941 951
942 952 def email_or_none(author):
943 953 # extract email from the commit string
944 954 _email = author_email(author)
945 955
946 956 # If we have an email, use it, otherwise
947 957 # see if it contains a username we can get an email from
948 958 if _email != '':
949 959 return _email
950 960 else:
951 961 user = User.get_by_username(
952 962 author_name(author), case_insensitive=True, cache=True)
953 963
954 964 if user is not None:
955 965 return user.email
956 966
957 967 # No valid email, not a valid user in the system, none!
958 968 return None
959 969
960 970
961 971 def link_to_user(author, length=0, **kwargs):
962 972 user = discover_user(author)
963 973 # user can be None, but if we have it already it means we can re-use it
964 974 # in the person() function, so we save 1 intensive-query
965 975 if user:
966 976 author = user
967 977
968 978 display_person = person(author, 'username_or_name_or_email')
969 979 if length:
970 980 display_person = shorter(display_person, length)
971 981
972 982 if user and user.username != user.DEFAULT_USER:
973 983 return link_to(
974 984 escape(display_person),
975 985 route_path('user_profile', username=user.username),
976 986 **kwargs)
977 987 else:
978 988 return escape(display_person)
979 989
980 990
981 991 def link_to_group(users_group_name, **kwargs):
982 992 return link_to(
983 993 escape(users_group_name),
984 994 route_path('user_group_profile', user_group_name=users_group_name),
985 995 **kwargs)
986 996
987 997
988 998 def person(author, show_attr="username_and_name"):
989 999 user = discover_user(author)
990 1000 if user:
991 1001 return getattr(user, show_attr)
992 1002 else:
993 1003 _author = author_name(author)
994 1004 _email = email(author)
995 1005 return _author or _email
996 1006
997 1007
998 1008 def author_string(email):
999 1009 if email:
1000 1010 user = User.get_by_email(email, case_insensitive=True, cache=True)
1001 1011 if user:
1002 1012 if user.first_name or user.last_name:
1003 1013 return '%s %s &lt;%s&gt;' % (
1004 1014 user.first_name, user.last_name, email)
1005 1015 else:
1006 1016 return email
1007 1017 else:
1008 1018 return email
1009 1019 else:
1010 1020 return None
1011 1021
1012 1022
1013 1023 def person_by_id(id_, show_attr="username_and_name"):
1014 1024 # attr to return from fetched user
1015 1025 person_getter = lambda usr: getattr(usr, show_attr)
1016 1026
1017 1027 #maybe it's an ID ?
1018 1028 if str(id_).isdigit() or isinstance(id_, int):
1019 1029 id_ = int(id_)
1020 1030 user = User.get(id_)
1021 1031 if user is not None:
1022 1032 return person_getter(user)
1023 1033 return id_
1024 1034
1025 1035
1026 1036 def gravatar_with_user(request, author, show_disabled=False, tooltip=False):
1027 1037 _render = request.get_partial_renderer('rhodecode:templates/base/base.mako')
1028 1038 return _render('gravatar_with_user', author, show_disabled=show_disabled, tooltip=tooltip)
1029 1039
1030 1040
1031 1041 tags_paterns = OrderedDict((
1032 1042 ('lang', (re.compile(r'\[(lang|language)\ \=\&gt;\ *([a-zA-Z\-\/\#\+\.]*)\]'),
1033 1043 '<div class="metatag" tag="lang">\\2</div>')),
1034 1044
1035 1045 ('see', (re.compile(r'\[see\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
1036 1046 '<div class="metatag" tag="see">see: \\1 </div>')),
1037 1047
1038 1048 ('url', (re.compile(r'\[url\ \=\&gt;\ \[([a-zA-Z0-9\ \.\-\_]+)\]\((http://|https://|/)(.*?)\)\]'),
1039 1049 '<div class="metatag" tag="url"> <a href="\\2\\3">\\1</a> </div>')),
1040 1050
1041 1051 ('license', (re.compile(r'\[license\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
1042 1052 '<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>')),
1043 1053
1044 1054 ('ref', (re.compile(r'\[(requires|recommends|conflicts|base)\ \=\&gt;\ *([a-zA-Z0-9\-\/]*)\]'),
1045 1055 '<div class="metatag" tag="ref \\1">\\1: <a href="/\\2">\\2</a></div>')),
1046 1056
1047 1057 ('state', (re.compile(r'\[(stable|featured|stale|dead|dev|deprecated)\]'),
1048 1058 '<div class="metatag" tag="state \\1">\\1</div>')),
1049 1059
1050 1060 # label in grey
1051 1061 ('label', (re.compile(r'\[([a-z]+)\]'),
1052 1062 '<div class="metatag" tag="label">\\1</div>')),
1053 1063
1054 1064 # generic catch all in grey
1055 1065 ('generic', (re.compile(r'\[([a-zA-Z0-9\.\-\_]+)\]'),
1056 1066 '<div class="metatag" tag="generic">\\1</div>')),
1057 1067 ))
1058 1068
1059 1069
1060 1070 def extract_metatags(value):
1061 1071 """
1062 1072 Extract supported meta-tags from given text value
1063 1073 """
1064 1074 tags = []
1065 1075 if not value:
1066 1076 return tags, ''
1067 1077
1068 1078 for key, val in tags_paterns.items():
1069 1079 pat, replace_html = val
1070 1080 tags.extend([(key, x.group()) for x in pat.finditer(value)])
1071 1081 value = pat.sub('', value)
1072 1082
1073 1083 return tags, value
1074 1084
1075 1085
1076 1086 def style_metatag(tag_type, value):
1077 1087 """
1078 1088 converts tags from value into html equivalent
1079 1089 """
1080 1090 if not value:
1081 1091 return ''
1082 1092
1083 1093 html_value = value
1084 1094 tag_data = tags_paterns.get(tag_type)
1085 1095 if tag_data:
1086 1096 pat, replace_html = tag_data
1087 1097 # convert to plain `unicode` instead of a markup tag to be used in
1088 1098 # regex expressions. safe_unicode doesn't work here
1089 1099 html_value = pat.sub(replace_html, value)
1090 1100
1091 1101 return html_value
1092 1102
1093 1103
1094 1104 def bool2icon(value, show_at_false=True):
1095 1105 """
1096 1106 Returns boolean value of a given value, represented as html element with
1097 1107 classes that will represent icons
1098 1108
1099 1109 :param value: given value to convert to html node
1100 1110 """
1101 1111
1102 1112 if value: # does bool conversion
1103 1113 return HTML.tag('i', class_="icon-true", title='True')
1104 1114 else: # not true as bool
1105 1115 if show_at_false:
1106 1116 return HTML.tag('i', class_="icon-false", title='False')
1107 1117 return HTML.tag('i')
1108 1118
1109 1119
1110 1120 def b64(inp):
1111 1121 return base64.b64encode(inp)
1112 1122
1113 1123 #==============================================================================
1114 1124 # PERMS
1115 1125 #==============================================================================
1116 1126 from rhodecode.lib.auth import (
1117 1127 HasPermissionAny, HasPermissionAll,
1118 1128 HasRepoPermissionAny, HasRepoPermissionAll, HasRepoGroupPermissionAll,
1119 1129 HasRepoGroupPermissionAny, HasRepoPermissionAnyApi, get_csrf_token,
1120 1130 csrf_token_key, AuthUser)
1121 1131
1122 1132
1123 1133 #==============================================================================
1124 1134 # GRAVATAR URL
1125 1135 #==============================================================================
1126 1136 class InitialsGravatar(object):
1127 1137 def __init__(self, email_address, first_name, last_name, size=30,
1128 1138 background=None, text_color='#fff'):
1129 1139 self.size = size
1130 1140 self.first_name = first_name
1131 1141 self.last_name = last_name
1132 1142 self.email_address = email_address
1133 1143 self.background = background or self.str2color(email_address)
1134 1144 self.text_color = text_color
1135 1145
1136 1146 def get_color_bank(self):
1137 1147 """
1138 1148 returns a predefined list of colors that gravatars can use.
1139 1149 Those are randomized distinct colors that guarantee readability and
1140 1150 uniqueness.
1141 1151
1142 1152 generated with: http://phrogz.net/css/distinct-colors.html
1143 1153 """
1144 1154 return [
1145 1155 '#bf3030', '#a67f53', '#00ff00', '#5989b3', '#392040', '#d90000',
1146 1156 '#402910', '#204020', '#79baf2', '#a700b3', '#bf6060', '#7f5320',
1147 1157 '#008000', '#003059', '#ee00ff', '#ff0000', '#8c4b00', '#007300',
1148 1158 '#005fb3', '#de73e6', '#ff4040', '#ffaa00', '#3df255', '#203140',
1149 1159 '#47004d', '#591616', '#664400', '#59b365', '#0d2133', '#83008c',
1150 1160 '#592d2d', '#bf9f60', '#73e682', '#1d3f73', '#73006b', '#402020',
1151 1161 '#b2862d', '#397341', '#597db3', '#e600d6', '#a60000', '#736039',
1152 1162 '#00b318', '#79aaf2', '#330d30', '#ff8080', '#403010', '#16591f',
1153 1163 '#002459', '#8c4688', '#e50000', '#ffbf40', '#00732e', '#102340',
1154 1164 '#bf60ac', '#8c4646', '#cc8800', '#00a642', '#1d3473', '#b32d98',
1155 1165 '#660e00', '#ffd580', '#80ffb2', '#7391e6', '#733967', '#d97b6c',
1156 1166 '#8c5e00', '#59b389', '#3967e6', '#590047', '#73281d', '#665200',
1157 1167 '#00e67a', '#2d50b3', '#8c2377', '#734139', '#b2982d', '#16593a',
1158 1168 '#001859', '#ff00aa', '#a65e53', '#ffcc00', '#0d3321', '#2d3959',
1159 1169 '#731d56', '#401610', '#4c3d00', '#468c6c', '#002ca6', '#d936a3',
1160 1170 '#d94c36', '#403920', '#36d9a3', '#0d1733', '#592d4a', '#993626',
1161 1171 '#cca300', '#00734d', '#46598c', '#8c005e', '#7f1100', '#8c7000',
1162 1172 '#00a66f', '#7382e6', '#b32d74', '#d9896c', '#ffe680', '#1d7362',
1163 1173 '#364cd9', '#73003d', '#d93a00', '#998a4d', '#59b3a1', '#5965b3',
1164 1174 '#e5007a', '#73341d', '#665f00', '#00b38f', '#0018b3', '#59163a',
1165 1175 '#b2502d', '#bfb960', '#00ffcc', '#23318c', '#a6537f', '#734939',
1166 1176 '#b2a700', '#104036', '#3d3df2', '#402031', '#e56739', '#736f39',
1167 1177 '#79f2ea', '#000059', '#401029', '#4c1400', '#ffee00', '#005953',
1168 1178 '#101040', '#990052', '#402820', '#403d10', '#00ffee', '#0000d9',
1169 1179 '#ff80c4', '#a66953', '#eeff00', '#00ccbe', '#8080ff', '#e673a1',
1170 1180 '#a62c00', '#474d00', '#1a3331', '#46468c', '#733950', '#662900',
1171 1181 '#858c23', '#238c85', '#0f0073', '#b20047', '#d9986c', '#becc00',
1172 1182 '#396f73', '#281d73', '#ff0066', '#ff6600', '#dee673', '#59adb3',
1173 1183 '#6559b3', '#590024', '#b2622d', '#98b32d', '#36ced9', '#332d59',
1174 1184 '#40001a', '#733f1d', '#526600', '#005359', '#242040', '#bf6079',
1175 1185 '#735039', '#cef23d', '#007780', '#5630bf', '#66001b', '#b24700',
1176 1186 '#acbf60', '#1d6273', '#25008c', '#731d34', '#a67453', '#50592d',
1177 1187 '#00ccff', '#6600ff', '#ff0044', '#4c1f00', '#8a994d', '#79daf2',
1178 1188 '#a173e6', '#d93662', '#402310', '#aaff00', '#2d98b3', '#8c40ff',
1179 1189 '#592d39', '#ff8c40', '#354020', '#103640', '#1a0040', '#331a20',
1180 1190 '#331400', '#334d00', '#1d5673', '#583973', '#7f0022', '#4c3626',
1181 1191 '#88cc00', '#36a3d9', '#3d0073', '#d9364c', '#33241a', '#698c23',
1182 1192 '#5995b3', '#300059', '#e57382', '#7f3300', '#366600', '#00aaff',
1183 1193 '#3a1659', '#733941', '#663600', '#74b32d', '#003c59', '#7f53a6',
1184 1194 '#73000f', '#ff8800', '#baf279', '#79caf2', '#291040', '#a6293a',
1185 1195 '#b2742d', '#587339', '#0077b3', '#632699', '#400009', '#d9a66c',
1186 1196 '#294010', '#2d4a59', '#aa00ff', '#4c131b', '#b25f00', '#5ce600',
1187 1197 '#267399', '#a336d9', '#990014', '#664e33', '#86bf60', '#0088ff',
1188 1198 '#7700b3', '#593a16', '#073300', '#1d4b73', '#ac60bf', '#e59539',
1189 1199 '#4f8c46', '#368dd9', '#5c0073'
1190 1200 ]
1191 1201
1192 1202 def rgb_to_hex_color(self, rgb_tuple):
1193 1203 """
1194 1204 Converts an rgb_tuple passed to an hex color.
1195 1205
1196 1206 :param rgb_tuple: tuple with 3 ints represents rgb color space
1197 1207 """
1198 1208 return '#' + ("".join(map(chr, rgb_tuple)).encode('hex'))
1199 1209
1200 1210 def email_to_int_list(self, email_str):
1201 1211 """
1202 1212 Get every byte of the hex digest value of email and turn it to integer.
1203 1213 It's going to be always between 0-255
1204 1214 """
1205 1215 digest = md5_safe(email_str.lower())
1206 1216 return [int(digest[i * 2:i * 2 + 2], 16) for i in range(16)]
1207 1217
1208 1218 def pick_color_bank_index(self, email_str, color_bank):
1209 1219 return self.email_to_int_list(email_str)[0] % len(color_bank)
1210 1220
1211 1221 def str2color(self, email_str):
1212 1222 """
1213 1223 Tries to map in a stable algorithm an email to color
1214 1224
1215 1225 :param email_str:
1216 1226 """
1217 1227 color_bank = self.get_color_bank()
1218 1228 # pick position (module it's length so we always find it in the
1219 1229 # bank even if it's smaller than 256 values
1220 1230 pos = self.pick_color_bank_index(email_str, color_bank)
1221 1231 return color_bank[pos]
1222 1232
1223 1233 def normalize_email(self, email_address):
1224 1234 import unicodedata
1225 1235 # default host used to fill in the fake/missing email
1226 1236 default_host = 'localhost'
1227 1237
1228 1238 if not email_address:
1229 1239 email_address = '%s@%s' % (User.DEFAULT_USER, default_host)
1230 1240
1231 1241 email_address = safe_unicode(email_address)
1232 1242
1233 1243 if u'@' not in email_address:
1234 1244 email_address = u'%s@%s' % (email_address, default_host)
1235 1245
1236 1246 if email_address.endswith(u'@'):
1237 1247 email_address = u'%s%s' % (email_address, default_host)
1238 1248
1239 1249 email_address = unicodedata.normalize('NFKD', email_address)\
1240 1250 .encode('ascii', 'ignore')
1241 1251 return email_address
1242 1252
1243 1253 def get_initials(self):
1244 1254 """
1245 1255 Returns 2 letter initials calculated based on the input.
1246 1256 The algorithm picks first given email address, and takes first letter
1247 1257 of part before @, and then the first letter of server name. In case
1248 1258 the part before @ is in a format of `somestring.somestring2` it replaces
1249 1259 the server letter with first letter of somestring2
1250 1260
1251 1261 In case function was initialized with both first and lastname, this
1252 1262 overrides the extraction from email by first letter of the first and
1253 1263 last name. We add special logic to that functionality, In case Full name
1254 1264 is compound, like Guido Von Rossum, we use last part of the last name
1255 1265 (Von Rossum) picking `R`.
1256 1266
1257 1267 Function also normalizes the non-ascii characters to they ascii
1258 1268 representation, eg Δ„ => A
1259 1269 """
1260 1270 import unicodedata
1261 1271 # replace non-ascii to ascii
1262 1272 first_name = unicodedata.normalize(
1263 1273 'NFKD', safe_unicode(self.first_name)).encode('ascii', 'ignore')
1264 1274 last_name = unicodedata.normalize(
1265 1275 'NFKD', safe_unicode(self.last_name)).encode('ascii', 'ignore')
1266 1276
1267 1277 # do NFKD encoding, and also make sure email has proper format
1268 1278 email_address = self.normalize_email(self.email_address)
1269 1279
1270 1280 # first push the email initials
1271 1281 prefix, server = email_address.split('@', 1)
1272 1282
1273 1283 # check if prefix is maybe a 'first_name.last_name' syntax
1274 1284 _dot_split = prefix.rsplit('.', 1)
1275 1285 if len(_dot_split) == 2 and _dot_split[1]:
1276 1286 initials = [_dot_split[0][0], _dot_split[1][0]]
1277 1287 else:
1278 1288 initials = [prefix[0], server[0]]
1279 1289
1280 1290 # then try to replace either first_name or last_name
1281 1291 fn_letter = (first_name or " ")[0].strip()
1282 1292 ln_letter = (last_name.split(' ', 1)[-1] or " ")[0].strip()
1283 1293
1284 1294 if fn_letter:
1285 1295 initials[0] = fn_letter
1286 1296
1287 1297 if ln_letter:
1288 1298 initials[1] = ln_letter
1289 1299
1290 1300 return ''.join(initials).upper()
1291 1301
1292 1302 def get_img_data_by_type(self, font_family, img_type):
1293 1303 default_user = """
1294 1304 <svg xmlns="http://www.w3.org/2000/svg"
1295 1305 version="1.1" x="0px" y="0px" width="{size}" height="{size}"
1296 1306 viewBox="-15 -10 439.165 429.164"
1297 1307
1298 1308 xml:space="preserve"
1299 1309 style="background:{background};" >
1300 1310
1301 1311 <path d="M204.583,216.671c50.664,0,91.74-48.075,
1302 1312 91.74-107.378c0-82.237-41.074-107.377-91.74-107.377
1303 1313 c-50.668,0-91.74,25.14-91.74,107.377C112.844,
1304 1314 168.596,153.916,216.671,
1305 1315 204.583,216.671z" fill="{text_color}"/>
1306 1316 <path d="M407.164,374.717L360.88,
1307 1317 270.454c-2.117-4.771-5.836-8.728-10.465-11.138l-71.83-37.392
1308 1318 c-1.584-0.823-3.502-0.663-4.926,0.415c-20.316,
1309 1319 15.366-44.203,23.488-69.076,23.488c-24.877,
1310 1320 0-48.762-8.122-69.078-23.488
1311 1321 c-1.428-1.078-3.346-1.238-4.93-0.415L58.75,
1312 1322 259.316c-4.631,2.41-8.346,6.365-10.465,11.138L2.001,374.717
1313 1323 c-3.191,7.188-2.537,15.412,1.75,22.005c4.285,
1314 1324 6.592,11.537,10.526,19.4,10.526h362.861c7.863,0,15.117-3.936,
1315 1325 19.402-10.527 C409.699,390.129,
1316 1326 410.355,381.902,407.164,374.717z" fill="{text_color}"/>
1317 1327 </svg>""".format(
1318 1328 size=self.size,
1319 1329 background='#979797', # @grey4
1320 1330 text_color=self.text_color,
1321 1331 font_family=font_family)
1322 1332
1323 1333 return {
1324 1334 "default_user": default_user
1325 1335 }[img_type]
1326 1336
1327 1337 def get_img_data(self, svg_type=None):
1328 1338 """
1329 1339 generates the svg metadata for image
1330 1340 """
1331 1341 fonts = [
1332 1342 '-apple-system',
1333 1343 'BlinkMacSystemFont',
1334 1344 'Segoe UI',
1335 1345 'Roboto',
1336 1346 'Oxygen-Sans',
1337 1347 'Ubuntu',
1338 1348 'Cantarell',
1339 1349 'Helvetica Neue',
1340 1350 'sans-serif'
1341 1351 ]
1342 1352 font_family = ','.join(fonts)
1343 1353 if svg_type:
1344 1354 return self.get_img_data_by_type(font_family, svg_type)
1345 1355
1346 1356 initials = self.get_initials()
1347 1357 img_data = """
1348 1358 <svg xmlns="http://www.w3.org/2000/svg" pointer-events="none"
1349 1359 width="{size}" height="{size}"
1350 1360 style="width: 100%; height: 100%; background-color: {background}"
1351 1361 viewBox="0 0 {size} {size}">
1352 1362 <text text-anchor="middle" y="50%" x="50%" dy="0.35em"
1353 1363 pointer-events="auto" fill="{text_color}"
1354 1364 font-family="{font_family}"
1355 1365 style="font-weight: 400; font-size: {f_size}px;">{text}
1356 1366 </text>
1357 1367 </svg>""".format(
1358 1368 size=self.size,
1359 1369 f_size=self.size/2.05, # scale the text inside the box nicely
1360 1370 background=self.background,
1361 1371 text_color=self.text_color,
1362 1372 text=initials.upper(),
1363 1373 font_family=font_family)
1364 1374
1365 1375 return img_data
1366 1376
1367 1377 def generate_svg(self, svg_type=None):
1368 1378 img_data = self.get_img_data(svg_type)
1369 1379 return "data:image/svg+xml;base64,%s" % base64.b64encode(img_data)
1370 1380
1371 1381
1372 1382 def initials_gravatar(request, email_address, first_name, last_name, size=30, store_on_disk=False):
1373 1383
1374 1384 svg_type = None
1375 1385 if email_address == User.DEFAULT_USER_EMAIL:
1376 1386 svg_type = 'default_user'
1377 1387
1378 1388 klass = InitialsGravatar(email_address, first_name, last_name, size)
1379 1389
1380 1390 if store_on_disk:
1381 1391 from rhodecode.apps.file_store import utils as store_utils
1382 1392 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, \
1383 1393 FileOverSizeException
1384 1394 from rhodecode.model.db import Session
1385 1395
1386 1396 image_key = md5_safe(email_address.lower()
1387 1397 + first_name.lower() + last_name.lower())
1388 1398
1389 1399 storage = store_utils.get_file_storage(request.registry.settings)
1390 1400 filename = '{}.svg'.format(image_key)
1391 1401 subdir = 'gravatars'
1392 1402 # since final name has a counter, we apply the 0
1393 1403 uid = storage.apply_counter(0, store_utils.uid_filename(filename, randomized=False))
1394 1404 store_uid = os.path.join(subdir, uid)
1395 1405
1396 1406 db_entry = FileStore.get_by_store_uid(store_uid)
1397 1407 if db_entry:
1398 1408 return request.route_path('download_file', fid=store_uid)
1399 1409
1400 1410 img_data = klass.get_img_data(svg_type=svg_type)
1401 1411 img_file = store_utils.bytes_to_file_obj(img_data)
1402 1412
1403 1413 try:
1404 1414 store_uid, metadata = storage.save_file(
1405 1415 img_file, filename, directory=subdir,
1406 1416 extensions=['.svg'], randomized_name=False)
1407 1417 except (FileNotAllowedException, FileOverSizeException):
1408 1418 raise
1409 1419
1410 1420 try:
1411 1421 entry = FileStore.create(
1412 1422 file_uid=store_uid, filename=metadata["filename"],
1413 1423 file_hash=metadata["sha256"], file_size=metadata["size"],
1414 1424 file_display_name=filename,
1415 1425 file_description=u'user gravatar `{}`'.format(safe_unicode(filename)),
1416 1426 hidden=True, check_acl=False, user_id=1
1417 1427 )
1418 1428 Session().add(entry)
1419 1429 Session().commit()
1420 1430 log.debug('Stored upload in DB as %s', entry)
1421 1431 except Exception:
1422 1432 raise
1423 1433
1424 1434 return request.route_path('download_file', fid=store_uid)
1425 1435
1426 1436 else:
1427 1437 return klass.generate_svg(svg_type=svg_type)
1428 1438
1429 1439
1430 1440 def gravatar_external(request, gravatar_url_tmpl, email_address, size=30):
1431 1441 return safe_str(gravatar_url_tmpl)\
1432 1442 .replace('{email}', email_address) \
1433 1443 .replace('{md5email}', md5_safe(email_address.lower())) \
1434 1444 .replace('{netloc}', request.host) \
1435 1445 .replace('{scheme}', request.scheme) \
1436 1446 .replace('{size}', safe_str(size))
1437 1447
1438 1448
1439 1449 def gravatar_url(email_address, size=30, request=None):
1440 1450 request = request or get_current_request()
1441 1451 _use_gravatar = request.call_context.visual.use_gravatar
1442 1452
1443 1453 email_address = email_address or User.DEFAULT_USER_EMAIL
1444 1454 if isinstance(email_address, str):
1445 1455 # hashlib crashes on unicode items
1446 1456 email_address = safe_str(email_address)
1447 1457
1448 1458 # empty email or default user
1449 1459 if not email_address or email_address == User.DEFAULT_USER_EMAIL:
1450 1460 return initials_gravatar(request, User.DEFAULT_USER_EMAIL, '', '', size=size)
1451 1461
1452 1462 if _use_gravatar:
1453 1463 gravatar_url_tmpl = request.call_context.visual.gravatar_url \
1454 1464 or User.DEFAULT_GRAVATAR_URL
1455 1465 return gravatar_external(request, gravatar_url_tmpl, email_address, size=size)
1456 1466
1457 1467 else:
1458 1468 return initials_gravatar(request, email_address, '', '', size=size)
1459 1469
1460 1470
1461 1471 def breadcrumb_repo_link(repo):
1462 1472 """
1463 1473 Makes a breadcrumbs path link to repo
1464 1474
1465 1475 ex::
1466 1476 group >> subgroup >> repo
1467 1477
1468 1478 :param repo: a Repository instance
1469 1479 """
1470 1480
1471 1481 path = [
1472 1482 link_to(group.name, route_path('repo_group_home', repo_group_name=group.group_name),
1473 1483 title='last change:{}'.format(format_date(group.last_commit_change)))
1474 1484 for group in repo.groups_with_parents
1475 1485 ] + [
1476 1486 link_to(repo.just_name, route_path('repo_summary', repo_name=repo.repo_name),
1477 1487 title='last change:{}'.format(format_date(repo.last_commit_change)))
1478 1488 ]
1479 1489
1480 1490 return literal(' &raquo; '.join(path))
1481 1491
1482 1492
1483 1493 def breadcrumb_repo_group_link(repo_group):
1484 1494 """
1485 1495 Makes a breadcrumbs path link to repo
1486 1496
1487 1497 ex::
1488 1498 group >> subgroup
1489 1499
1490 1500 :param repo_group: a Repository Group instance
1491 1501 """
1492 1502
1493 1503 path = [
1494 1504 link_to(group.name,
1495 1505 route_path('repo_group_home', repo_group_name=group.group_name),
1496 1506 title='last change:{}'.format(format_date(group.last_commit_change)))
1497 1507 for group in repo_group.parents
1498 1508 ] + [
1499 1509 link_to(repo_group.name,
1500 1510 route_path('repo_group_home', repo_group_name=repo_group.group_name),
1501 1511 title='last change:{}'.format(format_date(repo_group.last_commit_change)))
1502 1512 ]
1503 1513
1504 1514 return literal(' &raquo; '.join(path))
1505 1515
1506 1516
1507 1517 def format_byte_size_binary(file_size):
1508 1518 """
1509 1519 Formats file/folder sizes to standard.
1510 1520 """
1511 1521 if file_size is None:
1512 1522 file_size = 0
1513 1523
1514 1524 formatted_size = format_byte_size(file_size, binary=True)
1515 1525 return formatted_size
1516 1526
1517 1527
1518 1528 def urlify_text(text_, safe=True, **href_attrs):
1519 1529 """
1520 1530 Extract urls from text and make html links out of them
1521 1531 """
1522 1532
1523 1533 url_pat = re.compile(r'''(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@#.&+]'''
1524 1534 '''|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)''')
1525 1535
1526 1536 def url_func(match_obj):
1527 1537 url_full = match_obj.groups()[0]
1528 1538 a_options = dict(href_attrs)
1529 1539 a_options['href'] = url_full
1530 1540 a_text = url_full
1531 1541 return HTML.tag("a", a_text, **a_options)
1532 1542
1533 1543 _new_text = url_pat.sub(url_func, text_)
1534 1544
1535 1545 if safe:
1536 1546 return literal(_new_text)
1537 1547 return _new_text
1538 1548
1539 1549
1540 1550 def urlify_commits(text_, repo_name):
1541 1551 """
1542 1552 Extract commit ids from text and make link from them
1543 1553
1544 1554 :param text_:
1545 1555 :param repo_name: repo name to build the URL with
1546 1556 """
1547 1557
1548 1558 url_pat = re.compile(r'(^|\s)([0-9a-fA-F]{12,40})($|\s)')
1549 1559
1550 1560 def url_func(match_obj):
1551 1561 commit_id = match_obj.groups()[1]
1552 1562 pref = match_obj.groups()[0]
1553 1563 suf = match_obj.groups()[2]
1554 1564
1555 1565 tmpl = (
1556 1566 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-alt="%(hovercard_alt)s" data-hovercard-url="%(hovercard_url)s">'
1557 1567 '%(commit_id)s</a>%(suf)s'
1558 1568 )
1559 1569 return tmpl % {
1560 1570 'pref': pref,
1561 1571 'cls': 'revision-link',
1562 1572 'url': route_url(
1563 1573 'repo_commit', repo_name=repo_name, commit_id=commit_id),
1564 1574 'commit_id': commit_id,
1565 1575 'suf': suf,
1566 1576 'hovercard_alt': 'Commit: {}'.format(commit_id),
1567 1577 'hovercard_url': route_url(
1568 1578 'hovercard_repo_commit', repo_name=repo_name, commit_id=commit_id)
1569 1579 }
1570 1580
1571 1581 new_text = url_pat.sub(url_func, text_)
1572 1582
1573 1583 return new_text
1574 1584
1575 1585
1576 1586 def _process_url_func(match_obj, repo_name, uid, entry,
1577 1587 return_raw_data=False, link_format='html'):
1578 1588 pref = ''
1579 1589 if match_obj.group().startswith(' '):
1580 1590 pref = ' '
1581 1591
1582 1592 issue_id = ''.join(match_obj.groups())
1583 1593
1584 1594 if link_format == 'html':
1585 1595 tmpl = (
1586 1596 '%(pref)s<a class="tooltip %(cls)s" href="%(url)s" title="%(title)s">'
1587 1597 '%(issue-prefix)s%(id-repr)s'
1588 1598 '</a>')
1589 1599 elif link_format == 'html+hovercard':
1590 1600 tmpl = (
1591 1601 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-url="%(hovercard_url)s">'
1592 1602 '%(issue-prefix)s%(id-repr)s'
1593 1603 '</a>')
1594 1604 elif link_format in ['rst', 'rst+hovercard']:
1595 1605 tmpl = '`%(issue-prefix)s%(id-repr)s <%(url)s>`_'
1596 1606 elif link_format in ['markdown', 'markdown+hovercard']:
1597 1607 tmpl = '[%(pref)s%(issue-prefix)s%(id-repr)s](%(url)s)'
1598 1608 else:
1599 1609 raise ValueError('Bad link_format:{}'.format(link_format))
1600 1610
1601 1611 (repo_name_cleaned,
1602 1612 parent_group_name) = RepoGroupModel()._get_group_name_and_parent(repo_name)
1603 1613
1604 1614 # variables replacement
1605 1615 named_vars = {
1606 1616 'id': issue_id,
1607 1617 'repo': repo_name,
1608 1618 'repo_name': repo_name_cleaned,
1609 1619 'group_name': parent_group_name,
1610 1620 # set dummy keys so we always have them
1611 1621 'hostname': '',
1612 1622 'netloc': '',
1613 1623 'scheme': ''
1614 1624 }
1615 1625
1616 1626 request = get_current_request()
1617 1627 if request:
1618 1628 # exposes, hostname, netloc, scheme
1619 1629 host_data = get_host_info(request)
1620 1630 named_vars.update(host_data)
1621 1631
1622 1632 # named regex variables
1623 1633 named_vars.update(match_obj.groupdict())
1624 1634 _url = string.Template(entry['url']).safe_substitute(**named_vars)
1625 1635 desc = string.Template(escape(entry['desc'])).safe_substitute(**named_vars)
1626 1636 hovercard_url = string.Template(entry.get('hovercard_url', '')).safe_substitute(**named_vars)
1627 1637
1628 1638 def quote_cleaner(input_str):
1629 1639 """Remove quotes as it's HTML"""
1630 1640 return input_str.replace('"', '')
1631 1641
1632 1642 data = {
1633 1643 'pref': pref,
1634 1644 'cls': quote_cleaner('issue-tracker-link'),
1635 1645 'url': quote_cleaner(_url),
1636 1646 'id-repr': issue_id,
1637 1647 'issue-prefix': entry['pref'],
1638 1648 'serv': entry['url'],
1639 1649 'title': bleach.clean(desc, strip=True),
1640 1650 'hovercard_url': hovercard_url
1641 1651 }
1642 1652
1643 1653 if return_raw_data:
1644 1654 return {
1645 1655 'id': issue_id,
1646 1656 'url': _url
1647 1657 }
1648 1658 return tmpl % data
1649 1659
1650 1660
1651 1661 def get_active_pattern_entries(repo_name):
1652 1662 repo = None
1653 1663 if repo_name:
1654 1664 # Retrieving repo_name to avoid invalid repo_name to explode on
1655 1665 # IssueTrackerSettingsModel but still passing invalid name further down
1656 1666 repo = Repository.get_by_repo_name(repo_name, cache=True)
1657 1667
1658 1668 settings_model = IssueTrackerSettingsModel(repo=repo)
1659 1669 active_entries = settings_model.get_settings(cache=True)
1660 1670 return active_entries
1661 1671
1662 1672
1663 1673 pr_pattern_re = regex.compile(r'(?:(?:^!)|(?: !))(\d+)')
1664 1674
1665 1675 allowed_link_formats = [
1666 1676 'html', 'rst', 'markdown', 'html+hovercard', 'rst+hovercard', 'markdown+hovercard']
1667 1677
1668 1678 compile_cache = {
1669 1679
1670 1680 }
1671 1681
1672 1682
1673 1683 def process_patterns(text_string, repo_name, link_format='html', active_entries=None):
1674 1684
1675 1685 if link_format not in allowed_link_formats:
1676 1686 raise ValueError('Link format can be only one of:{} got {}'.format(
1677 1687 allowed_link_formats, link_format))
1678 1688 issues_data = []
1679 1689 errors = []
1680 1690 new_text = text_string
1681 1691
1682 1692 if active_entries is None:
1683 1693 log.debug('Fetch active issue tracker patterns for repo: %s', repo_name)
1684 1694 active_entries = get_active_pattern_entries(repo_name)
1685 1695
1686 1696 log.debug('Got %s pattern entries to process', len(active_entries))
1687 1697
1688 1698 for uid, entry in active_entries.items():
1689 1699
1690 1700 if not (entry['pat'] and entry['url']):
1691 1701 log.debug('skipping due to missing data')
1692 1702 continue
1693 1703
1694 1704 log.debug('issue tracker entry: uid: `%s` PAT:%s URL:%s PREFIX:%s',
1695 1705 uid, entry['pat'], entry['url'], entry['pref'])
1696 1706
1697 1707 if entry.get('pat_compiled'):
1698 1708 pattern = entry['pat_compiled']
1699 1709 elif entry['pat'] in compile_cache:
1700 1710 pattern = compile_cache[entry['pat']]
1701 1711 else:
1702 1712 try:
1703 1713 pattern = regex.compile(r'%s' % entry['pat'])
1704 1714 except regex.error as e:
1705 1715 regex_err = ValueError('{}:{}'.format(entry['pat'], e))
1706 1716 log.exception('issue tracker pattern: `%s` failed to compile', regex_err)
1707 1717 errors.append(regex_err)
1708 1718 continue
1709 1719 compile_cache[entry['pat']] = pattern
1710 1720
1711 1721 data_func = partial(
1712 1722 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1713 1723 return_raw_data=True)
1714 1724
1715 1725 for match_obj in pattern.finditer(text_string):
1716 1726 issues_data.append(data_func(match_obj))
1717 1727
1718 1728 url_func = partial(
1719 1729 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1720 1730 link_format=link_format)
1721 1731
1722 1732 new_text = pattern.sub(url_func, new_text)
1723 1733 log.debug('processed prefix:uid `%s`', uid)
1724 1734
1725 1735 # finally use global replace, eg !123 -> pr-link, those will not catch
1726 1736 # if already similar pattern exists
1727 1737 server_url = '${scheme}://${netloc}'
1728 1738 pr_entry = {
1729 1739 'pref': '!',
1730 1740 'url': server_url + '/_admin/pull-requests/${id}',
1731 1741 'desc': 'Pull Request !${id}',
1732 1742 'hovercard_url': server_url + '/_hovercard/pull_request/${id}'
1733 1743 }
1734 1744 pr_url_func = partial(
1735 1745 _process_url_func, repo_name=repo_name, entry=pr_entry, uid=None,
1736 1746 link_format=link_format+'+hovercard')
1737 1747 new_text = pr_pattern_re.sub(pr_url_func, new_text)
1738 1748 log.debug('processed !pr pattern')
1739 1749
1740 1750 return new_text, issues_data, errors
1741 1751
1742 1752
1743 1753 def urlify_commit_message(commit_text, repository=None, active_pattern_entries=None,
1744 1754 issues_container_callback=None, error_container=None):
1745 1755 """
1746 1756 Parses given text message and makes proper links.
1747 1757 issues are linked to given issue-server, and rest is a commit link
1748 1758 """
1749 1759
1750 1760 def escaper(_text):
1751 1761 return _text.replace('<', '&lt;').replace('>', '&gt;')
1752 1762
1753 1763 new_text = escaper(commit_text)
1754 1764
1755 1765 # extract http/https links and make them real urls
1756 1766 new_text = urlify_text(new_text, safe=False)
1757 1767
1758 1768 # urlify commits - extract commit ids and make link out of them, if we have
1759 1769 # the scope of repository present.
1760 1770 if repository:
1761 1771 new_text = urlify_commits(new_text, repository)
1762 1772
1763 1773 # process issue tracker patterns
1764 1774 new_text, issues, errors = process_patterns(
1765 1775 new_text, repository or '', active_entries=active_pattern_entries)
1766 1776
1767 1777 if issues_container_callback is not None:
1768 1778 for issue in issues:
1769 1779 issues_container_callback(issue)
1770 1780
1771 1781 if error_container is not None:
1772 1782 error_container.extend(errors)
1773 1783
1774 1784 return literal(new_text)
1775 1785
1776 1786
1777 1787 def render_binary(repo_name, file_obj):
1778 1788 """
1779 1789 Choose how to render a binary file
1780 1790 """
1781 1791
1782 1792 # unicode
1783 1793 filename = file_obj.name
1784 1794
1785 1795 # images
1786 1796 for ext in ['*.png', '*.jpeg', '*.jpg', '*.ico', '*.gif']:
1787 1797 if fnmatch.fnmatch(filename, pat=ext):
1788 1798 src = route_path(
1789 1799 'repo_file_raw', repo_name=repo_name,
1790 1800 commit_id=file_obj.commit.raw_id,
1791 1801 f_path=file_obj.path)
1792 1802
1793 1803 return literal(
1794 1804 '<img class="rendered-binary" alt="rendered-image" src="{}">'.format(src))
1795 1805
1796 1806
1797 1807 def renderer_from_filename(filename, exclude=None):
1798 1808 """
1799 1809 choose a renderer based on filename, this works only for text based files
1800 1810 """
1801 1811
1802 1812 # ipython
1803 1813 for ext in ['*.ipynb']:
1804 1814 if fnmatch.fnmatch(filename, pat=ext):
1805 1815 return 'jupyter'
1806 1816
1807 1817 is_markup = MarkupRenderer.renderer_from_filename(filename, exclude=exclude)
1808 1818 if is_markup:
1809 1819 return is_markup
1810 1820 return None
1811 1821
1812 1822
1813 1823 def render(source, renderer='rst', mentions=False, relative_urls=None,
1814 1824 repo_name=None, active_pattern_entries=None, issues_container_callback=None):
1815 1825
1816 1826 def maybe_convert_relative_links(html_source):
1817 1827 if relative_urls:
1818 1828 return relative_links(html_source, relative_urls)
1819 1829 return html_source
1820 1830
1821 1831 if renderer == 'plain':
1822 1832 return literal(
1823 1833 MarkupRenderer.plain(source, leading_newline=False))
1824 1834
1825 1835 elif renderer == 'rst':
1826 1836 if repo_name:
1827 1837 # process patterns on comments if we pass in repo name
1828 1838 source, issues, errors = process_patterns(
1829 1839 source, repo_name, link_format='rst',
1830 1840 active_entries=active_pattern_entries)
1831 1841 if issues_container_callback is not None:
1832 1842 for issue in issues:
1833 1843 issues_container_callback(issue)
1834 1844
1835 1845 return literal(
1836 1846 '<div class="rst-block">%s</div>' %
1837 1847 maybe_convert_relative_links(
1838 1848 MarkupRenderer.rst(source, mentions=mentions)))
1839 1849
1840 1850 elif renderer == 'markdown':
1841 1851 if repo_name:
1842 1852 # process patterns on comments if we pass in repo name
1843 1853 source, issues, errors = process_patterns(
1844 1854 source, repo_name, link_format='markdown',
1845 1855 active_entries=active_pattern_entries)
1846 1856 if issues_container_callback is not None:
1847 1857 for issue in issues:
1848 1858 issues_container_callback(issue)
1849 1859
1850 1860
1851 1861 return literal(
1852 1862 '<div class="markdown-block">%s</div>' %
1853 1863 maybe_convert_relative_links(
1854 1864 MarkupRenderer.markdown(source, flavored=True,
1855 1865 mentions=mentions)))
1856 1866
1857 1867 elif renderer == 'jupyter':
1858 1868 return literal(
1859 1869 '<div class="ipynb">%s</div>' %
1860 1870 maybe_convert_relative_links(
1861 1871 MarkupRenderer.jupyter(source)))
1862 1872
1863 1873 # None means just show the file-source
1864 1874 return None
1865 1875
1866 1876
1867 1877 def commit_status(repo, commit_id):
1868 1878 return ChangesetStatusModel().get_status(repo, commit_id)
1869 1879
1870 1880
1871 1881 def commit_status_lbl(commit_status):
1872 1882 return dict(ChangesetStatus.STATUSES).get(commit_status)
1873 1883
1874 1884
1875 1885 def commit_time(repo_name, commit_id):
1876 1886 repo = Repository.get_by_repo_name(repo_name)
1877 1887 commit = repo.get_commit(commit_id=commit_id)
1878 1888 return commit.date
1879 1889
1880 1890
1881 1891 def get_permission_name(key):
1882 1892 return dict(Permission.PERMS).get(key)
1883 1893
1884 1894
1885 1895 def journal_filter_help(request):
1886 1896 _ = request.translate
1887 1897 from rhodecode.lib.audit_logger import ACTIONS
1888 1898 actions = '\n'.join(textwrap.wrap(', '.join(sorted(ACTIONS.keys())), 80))
1889 1899
1890 1900 return _(
1891 1901 'Example filter terms:\n' +
1892 1902 ' repository:vcs\n' +
1893 1903 ' username:marcin\n' +
1894 1904 ' username:(NOT marcin)\n' +
1895 1905 ' action:*push*\n' +
1896 1906 ' ip:127.0.0.1\n' +
1897 1907 ' date:20120101\n' +
1898 1908 ' date:[20120101100000 TO 20120102]\n' +
1899 1909 '\n' +
1900 1910 'Actions: {actions}\n' +
1901 1911 '\n' +
1902 1912 'Generate wildcards using \'*\' character:\n' +
1903 1913 ' "repository:vcs*" - search everything starting with \'vcs\'\n' +
1904 1914 ' "repository:*vcs*" - search for repository containing \'vcs\'\n' +
1905 1915 '\n' +
1906 1916 'Optional AND / OR operators in queries\n' +
1907 1917 ' "repository:vcs OR repository:test"\n' +
1908 1918 ' "username:test AND repository:test*"\n'
1909 1919 ).format(actions=actions)
1910 1920
1911 1921
1912 1922 def not_mapped_error(repo_name):
1913 1923 from rhodecode.translation import _
1914 1924 flash(_('%s repository is not mapped to db perhaps'
1915 1925 ' it was created or renamed from the filesystem'
1916 1926 ' please run the application again'
1917 1927 ' in order to rescan repositories') % repo_name, category='error')
1918 1928
1919 1929
1920 1930 def ip_range(ip_addr):
1921 1931 from rhodecode.model.db import UserIpMap
1922 1932 s, e = UserIpMap._get_ip_range(ip_addr)
1923 1933 return '%s - %s' % (s, e)
1924 1934
1925 1935
1926 1936 def form(url, method='post', needs_csrf_token=True, **attrs):
1927 1937 """Wrapper around webhelpers.tags.form to prevent CSRF attacks."""
1928 1938 if method.lower() != 'get' and needs_csrf_token:
1929 1939 raise Exception(
1930 1940 'Forms to POST/PUT/DELETE endpoints should have (in general) a ' +
1931 1941 'CSRF token. If the endpoint does not require such token you can ' +
1932 1942 'explicitly set the parameter needs_csrf_token to false.')
1933 1943
1934 1944 return insecure_form(url, method=method, **attrs)
1935 1945
1936 1946
1937 1947 def secure_form(form_url, method="POST", multipart=False, **attrs):
1938 1948 """Start a form tag that points the action to an url. This
1939 1949 form tag will also include the hidden field containing
1940 1950 the auth token.
1941 1951
1942 1952 The url options should be given either as a string, or as a
1943 1953 ``url()`` function. The method for the form defaults to POST.
1944 1954
1945 1955 Options:
1946 1956
1947 1957 ``multipart``
1948 1958 If set to True, the enctype is set to "multipart/form-data".
1949 1959 ``method``
1950 1960 The method to use when submitting the form, usually either
1951 1961 "GET" or "POST". If "PUT", "DELETE", or another verb is used, a
1952 1962 hidden input with name _method is added to simulate the verb
1953 1963 over POST.
1954 1964
1955 1965 """
1956 1966
1957 1967 if 'request' in attrs:
1958 1968 session = attrs['request'].session
1959 1969 del attrs['request']
1960 1970 else:
1961 1971 raise ValueError(
1962 1972 'Calling this form requires request= to be passed as argument')
1963 1973
1964 1974 _form = insecure_form(form_url, method, multipart, **attrs)
1965 1975 token = literal(
1966 1976 '<input type="hidden" name="{}" value="{}">'.format(
1967 1977 csrf_token_key, get_csrf_token(session)))
1968 1978
1969 1979 return literal("%s\n%s" % (_form, token))
1970 1980
1971 1981
1972 1982 def dropdownmenu(name, selected, options, enable_filter=False, **attrs):
1973 1983 select_html = select(name, selected, options, **attrs)
1974 1984
1975 1985 select2 = """
1976 1986 <script>
1977 1987 $(document).ready(function() {
1978 1988 $('#%s').select2({
1979 1989 containerCssClass: 'drop-menu %s',
1980 1990 dropdownCssClass: 'drop-menu-dropdown',
1981 1991 dropdownAutoWidth: true%s
1982 1992 });
1983 1993 });
1984 1994 </script>
1985 1995 """
1986 1996
1987 1997 filter_option = """,
1988 1998 minimumResultsForSearch: -1
1989 1999 """
1990 2000 input_id = attrs.get('id') or name
1991 2001 extra_classes = ' '.join(attrs.pop('extra_classes', []))
1992 2002 filter_enabled = "" if enable_filter else filter_option
1993 2003 select_script = literal(select2 % (input_id, extra_classes, filter_enabled))
1994 2004
1995 2005 return literal(select_html+select_script)
1996 2006
1997 2007
1998 2008 def get_visual_attr(tmpl_context_var, attr_name):
1999 2009 """
2000 2010 A safe way to get a variable from visual variable of template context
2001 2011
2002 2012 :param tmpl_context_var: instance of tmpl_context, usually present as `c`
2003 2013 :param attr_name: name of the attribute we fetch from the c.visual
2004 2014 """
2005 2015 visual = getattr(tmpl_context_var, 'visual', None)
2006 2016 if not visual:
2007 2017 return
2008 2018 else:
2009 2019 return getattr(visual, attr_name, None)
2010 2020
2011 2021
2012 2022 def get_last_path_part(file_node):
2013 2023 if not file_node.path:
2014 2024 return u'/'
2015 2025
2016 2026 path = safe_unicode(file_node.path.split('/')[-1])
2017 2027 return u'../' + path
2018 2028
2019 2029
2020 2030 def route_url(*args, **kwargs):
2021 2031 """
2022 2032 Wrapper around pyramids `route_url` (fully qualified url) function.
2023 2033 """
2024 2034 req = get_current_request()
2025 2035 return req.route_url(*args, **kwargs)
2026 2036
2027 2037
2028 2038 def route_path(*args, **kwargs):
2029 2039 """
2030 2040 Wrapper around pyramids `route_path` function.
2031 2041 """
2032 2042 req = get_current_request()
2033 2043 return req.route_path(*args, **kwargs)
2034 2044
2035 2045
2036 2046 def route_path_or_none(*args, **kwargs):
2037 2047 try:
2038 2048 return route_path(*args, **kwargs)
2039 2049 except KeyError:
2040 2050 return None
2041 2051
2042 2052
2043 2053 def current_route_path(request, **kw):
2044 2054 new_args = request.GET.mixed()
2045 2055 new_args.update(kw)
2046 2056 return request.current_route_path(_query=new_args)
2047 2057
2048 2058
2049 2059 def curl_api_example(method, args):
2050 2060 args_json = json.dumps(OrderedDict([
2051 2061 ('id', 1),
2052 2062 ('auth_token', 'SECRET'),
2053 2063 ('method', method),
2054 2064 ('args', args)
2055 2065 ]))
2056 2066
2057 2067 return "curl {api_url} -X POST -H 'content-type:text/plain' --data-binary '{args_json}'".format(
2058 2068 api_url=route_url('apiv2'),
2059 2069 args_json=args_json
2060 2070 )
2061 2071
2062 2072
2063 2073 def api_call_example(method, args):
2064 2074 """
2065 2075 Generates an API call example via CURL
2066 2076 """
2067 2077 curl_call = curl_api_example(method, args)
2068 2078
2069 2079 return literal(
2070 2080 curl_call +
2071 2081 "<br/><br/>SECRET can be found in <a href=\"{token_url}\">auth-tokens</a> page, "
2072 2082 "and needs to be of `api calls` role."
2073 2083 .format(token_url=route_url('my_account_auth_tokens')))
2074 2084
2075 2085
2076 2086 def notification_description(notification, request):
2077 2087 """
2078 2088 Generate notification human readable description based on notification type
2079 2089 """
2080 2090 from rhodecode.model.notification import NotificationModel
2081 2091 return NotificationModel().make_description(
2082 2092 notification, translate=request.translate)
2083 2093
2084 2094
2085 2095 def go_import_header(request, db_repo=None):
2086 2096 """
2087 2097 Creates a header for go-import functionality in Go Lang
2088 2098 """
2089 2099
2090 2100 if not db_repo:
2091 2101 return
2092 2102 if 'go-get' not in request.GET:
2093 2103 return
2094 2104
2095 2105 clone_url = db_repo.clone_url()
2096 2106 prefix = re.split(r'^https?:\/\/', clone_url)[-1]
2097 2107 # we have a repo and go-get flag,
2098 2108 return literal('<meta name="go-import" content="{} {} {}">'.format(
2099 2109 prefix, db_repo.repo_type, clone_url))
2100 2110
2101 2111
2102 2112 def reviewer_as_json(*args, **kwargs):
2103 2113 from rhodecode.apps.repository.utils import reviewer_as_json as _reviewer_as_json
2104 2114 return _reviewer_as_json(*args, **kwargs)
2105 2115
2106 2116
2107 2117 def get_repo_view_type(request):
2108 2118 route_name = request.matched_route.name
2109 2119 route_to_view_type = {
2110 2120 'repo_changelog': 'commits',
2111 2121 'repo_commits': 'commits',
2112 2122 'repo_files': 'files',
2113 2123 'repo_summary': 'summary',
2114 2124 'repo_commit': 'commit'
2115 2125 }
2116 2126
2117 2127 return route_to_view_type.get(route_name)
2118 2128
2119 2129
2120 2130 def is_active(menu_entry, selected):
2121 2131 """
2122 2132 Returns active class for selecting menus in templates
2123 2133 <li class=${h.is_active('settings', current_active)}></li>
2124 2134 """
2125 2135 if not isinstance(menu_entry, list):
2126 2136 menu_entry = [menu_entry]
2127 2137
2128 2138 if selected in menu_entry:
2129 2139 return "active"
2130 2140
2131 2141
2132 2142 class IssuesRegistry(object):
2133 2143 """
2134 2144 issue_registry = IssuesRegistry()
2135 2145 some_func(issues_callback=issues_registry(...))
2136 2146 """
2137 2147
2138 2148 def __init__(self):
2139 2149 self.issues = []
2140 2150 self.unique_issues = collections.defaultdict(lambda: [])
2141 2151
2142 2152 def __call__(self, commit_dict=None):
2143 2153 def callback(issue):
2144 2154 if commit_dict and issue:
2145 2155 issue['commit'] = commit_dict
2146 2156 self.issues.append(issue)
2147 2157 self.unique_issues[issue['id']].append(issue)
2148 2158 return callback
2149 2159
2150 2160 def get_issues(self):
2151 2161 return self.issues
2152 2162
2153 2163 @property
2154 2164 def issues_unique_count(self):
2155 2165 return len(set(i['id'] for i in self.issues))
@@ -1,398 +1,398 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 import io
21 21 import shlex
22 22
23 23 import math
24 24 import re
25 25 import os
26 26 import datetime
27 27 import logging
28 28 import queue
29 29 import subprocess
30 30
31 31
32 32 from dateutil.parser import parse
33 33 from pyramid.threadlocal import get_current_request
34 34 from pyramid.interfaces import IRoutesMapper
35 35 from pyramid.settings import asbool
36 36 from pyramid.path import AssetResolver
37 37 from threading import Thread
38 38
39 39 from rhodecode.config.jsroutes import generate_jsroutes_content
40 40 from rhodecode.lib.base import get_auth_user
41 41
42 42 import rhodecode
43 43
44 44
45 45 log = logging.getLogger(__name__)
46 46
47 47
48 48 def add_renderer_globals(event):
49 49 from rhodecode.lib import helpers
50 50
51 51 # TODO: When executed in pyramid view context the request is not available
52 52 # in the event. Find a better solution to get the request.
53 53 request = event['request'] or get_current_request()
54 54
55 55 # Add Pyramid translation as '_' to context
56 56 event['_'] = request.translate
57 57 event['_ungettext'] = request.plularize
58 58 event['h'] = helpers
59 59
60 60
61 61 def set_user_lang(event):
62 62 request = event.request
63 63 cur_user = getattr(request, 'user', None)
64 64
65 65 if cur_user:
66 66 user_lang = cur_user.get_instance().user_data.get('language')
67 67 if user_lang:
68 68 log.debug('lang: setting current user:%s language to: %s', cur_user, user_lang)
69 69 event.request._LOCALE_ = user_lang
70 70
71 71
72 72 def update_celery_conf(event):
73 73 from rhodecode.lib.celerylib.loader import set_celery_conf
74 74 log.debug('Setting celery config from new request')
75 75 set_celery_conf(request=event.request, registry=event.request.registry)
76 76
77 77
78 78 def add_request_user_context(event):
79 79 """
80 80 Adds auth user into request context
81 81 """
82 82 request = event.request
83 83 # access req_id as soon as possible
84 84 req_id = request.req_id
85 85
86 86 if hasattr(request, 'vcs_call'):
87 87 # skip vcs calls
88 88 return
89 89
90 90 if hasattr(request, 'rpc_method'):
91 91 # skip api calls
92 92 return
93 93
94 94 auth_user, auth_token = get_auth_user(request)
95 95 request.user = auth_user
96 96 request.user_auth_token = auth_token
97 97 request.environ['rc_auth_user'] = auth_user
98 98 request.environ['rc_auth_user_id'] = auth_user.user_id
99 99 request.environ['rc_req_id'] = req_id
100 100
101 101
102 102 def reset_log_bucket(event):
103 103 """
104 104 reset the log bucket on new request
105 105 """
106 106 request = event.request
107 107 request.req_id_records_init()
108 108
109 109
110 110 def scan_repositories_if_enabled(event):
111 111 """
112 112 This is subscribed to the `pyramid.events.ApplicationCreated` event. It
113 113 does a repository scan if enabled in the settings.
114 114 """
115 115 settings = event.app.registry.settings
116 116 vcs_server_enabled = settings['vcs.server.enable']
117 117 import_on_startup = settings['startup.import_repos']
118 118 if vcs_server_enabled and import_on_startup:
119 119 from rhodecode.model.scm import ScmModel
120 120 from rhodecode.lib.utils import repo2db_mapper, get_rhodecode_base_path
121 121 repositories = ScmModel().repo_scan(get_rhodecode_base_path())
122 122 repo2db_mapper(repositories, remove_obsolete=False)
123 123
124 124
125 125 def write_metadata_if_needed(event):
126 126 """
127 127 Writes upgrade metadata
128 128 """
129 129 import rhodecode
130 130 from rhodecode.lib import system_info
131 131 from rhodecode.lib import ext_json
132 132
133 133 fname = '.rcmetadata.json'
134 134 ini_loc = os.path.dirname(rhodecode.CONFIG.get('__file__'))
135 135 metadata_destination = os.path.join(ini_loc, fname)
136 136
137 137 def get_update_age():
138 138 now = datetime.datetime.utcnow()
139 139
140 140 with open(metadata_destination, 'rb') as f:
141 141 data = ext_json.json.loads(f.read())
142 142 if 'created_on' in data:
143 143 update_date = parse(data['created_on'])
144 144 diff = now - update_date
145 145 return diff.total_seconds() / 60.0
146 146
147 147 return 0
148 148
149 149 def write():
150 150 configuration = system_info.SysInfo(
151 151 system_info.rhodecode_config)()['value']
152 152 license_token = configuration['config']['license_token']
153 153
154 154 setup = dict(
155 155 workers=configuration['config']['server:main'].get(
156 156 'workers', '?'),
157 157 worker_type=configuration['config']['server:main'].get(
158 158 'worker_class', 'sync'),
159 159 )
160 160 dbinfo = system_info.SysInfo(system_info.database_info)()['value']
161 161 del dbinfo['url']
162 162
163 163 metadata = dict(
164 164 desc='upgrade metadata info',
165 165 license_token=license_token,
166 166 created_on=datetime.datetime.utcnow().isoformat(),
167 167 usage=system_info.SysInfo(system_info.usage_info)()['value'],
168 168 platform=system_info.SysInfo(system_info.platform_type)()['value'],
169 169 database=dbinfo,
170 170 cpu=system_info.SysInfo(system_info.cpu)()['value'],
171 171 memory=system_info.SysInfo(system_info.memory)()['value'],
172 172 setup=setup
173 173 )
174 174
175 175 with open(metadata_destination, 'wb') as f:
176 176 f.write(ext_json.json.dumps(metadata))
177 177
178 178 settings = event.app.registry.settings
179 179 if settings.get('metadata.skip'):
180 180 return
181 181
182 182 # only write this every 24h, workers restart caused unwanted delays
183 183 try:
184 184 age_in_min = get_update_age()
185 185 except Exception:
186 186 age_in_min = 0
187 187
188 188 if age_in_min > 60 * 60 * 24:
189 189 return
190 190
191 191 try:
192 192 write()
193 193 except Exception:
194 194 pass
195 195
196 196
197 197 def write_usage_data(event):
198 198 import rhodecode
199 199 from rhodecode.lib import system_info
200 200 from rhodecode.lib import ext_json
201 201
202 202 settings = event.app.registry.settings
203 203 instance_tag = settings.get('metadata.write_usage_tag')
204 204 if not settings.get('metadata.write_usage'):
205 205 return
206 206
207 207 def get_update_age(dest_file):
208 208 now = datetime.datetime.utcnow()
209 209
210 210 with open(dest_file, 'rb') as f:
211 211 data = ext_json.json.loads(f.read())
212 212 if 'created_on' in data:
213 213 update_date = parse(data['created_on'])
214 214 diff = now - update_date
215 215 return math.ceil(diff.total_seconds() / 60.0)
216 216
217 217 return 0
218 218
219 219 utc_date = datetime.datetime.utcnow()
220 220 hour_quarter = int(math.ceil((utc_date.hour + utc_date.minute/60.0) / 6.))
221 221 fname = '.rc_usage_{date.year}{date.month:02d}{date.day:02d}_{hour}.json'.format(
222 222 date=utc_date, hour=hour_quarter)
223 223 ini_loc = os.path.dirname(rhodecode.CONFIG.get('__file__'))
224 224
225 225 usage_dir = os.path.join(ini_loc, '.rcusage')
226 226 if not os.path.isdir(usage_dir):
227 227 os.makedirs(usage_dir)
228 228 usage_metadata_destination = os.path.join(usage_dir, fname)
229 229
230 230 try:
231 231 age_in_min = get_update_age(usage_metadata_destination)
232 232 except Exception:
233 233 age_in_min = 0
234 234
235 235 # write every 6th hour
236 236 if age_in_min and age_in_min < 60 * 6:
237 237 log.debug('Usage file created %s minutes ago, skipping (threshold: %s minutes)...',
238 238 age_in_min, 60 * 6)
239 239 return
240 240
241 241 def write(dest_file):
242 242 configuration = system_info.SysInfo(system_info.rhodecode_config)()['value']
243 243 license_token = configuration['config']['license_token']
244 244
245 245 metadata = dict(
246 246 desc='Usage data',
247 247 instance_tag=instance_tag,
248 248 license_token=license_token,
249 249 created_on=datetime.datetime.utcnow().isoformat(),
250 250 usage=system_info.SysInfo(system_info.usage_info)()['value'],
251 251 )
252 252
253 253 with open(dest_file, 'wb') as f:
254 f.write(ext_json.json.dumps(metadata, indent=2, sort_keys=True))
254 f.write(ext_json.formatted_json(metadata))
255 255
256 256 try:
257 257 log.debug('Writing usage file at: %s', usage_metadata_destination)
258 258 write(usage_metadata_destination)
259 259 except Exception:
260 260 pass
261 261
262 262
263 263 def write_js_routes_if_enabled(event):
264 264 registry = event.app.registry
265 265
266 266 mapper = registry.queryUtility(IRoutesMapper)
267 267 _argument_prog = re.compile(r'\{(.*?)\}|:\((.*)\)')
268 268
269 269 def _extract_route_information(route):
270 270 """
271 271 Convert a route into tuple(name, path, args), eg:
272 272 ('show_user', '/profile/%(username)s', ['username'])
273 273 """
274 274
275 275 routepath = route.pattern
276 276 pattern = route.pattern
277 277
278 278 def replace(matchobj):
279 279 if matchobj.group(1):
280 280 return "%%(%s)s" % matchobj.group(1).split(':')[0]
281 281 else:
282 282 return "%%(%s)s" % matchobj.group(2)
283 283
284 284 routepath = _argument_prog.sub(replace, routepath)
285 285
286 286 if not routepath.startswith('/'):
287 287 routepath = '/'+routepath
288 288
289 289 return (
290 290 route.name,
291 291 routepath,
292 292 [(arg[0].split(':')[0] if arg[0] != '' else arg[1])
293 293 for arg in _argument_prog.findall(pattern)]
294 294 )
295 295
296 296 def get_routes():
297 297 # pyramid routes
298 298 for route in mapper.get_routes():
299 299 if not route.name.startswith('__'):
300 300 yield _extract_route_information(route)
301 301
302 302 if asbool(registry.settings.get('generate_js_files', 'false')):
303 303 static_path = AssetResolver().resolve('rhodecode:public').abspath()
304 304 jsroutes = get_routes()
305 305 jsroutes_file_content = generate_jsroutes_content(jsroutes)
306 306 jsroutes_file_path = os.path.join(
307 307 static_path, 'js', 'rhodecode', 'routes.js')
308 308
309 309 try:
310 310 with io.open(jsroutes_file_path, 'w', encoding='utf-8') as f:
311 311 f.write(jsroutes_file_content)
312 312 except Exception:
313 313 log.exception('Failed to write routes.js into %s', jsroutes_file_path)
314 314
315 315
316 316 class Subscriber(object):
317 317 """
318 318 Base class for subscribers to the pyramid event system.
319 319 """
320 320 def __call__(self, event):
321 321 self.run(event)
322 322
323 323 def run(self, event):
324 324 raise NotImplementedError('Subclass has to implement this.')
325 325
326 326
327 327 class AsyncSubscriber(Subscriber):
328 328 """
329 329 Subscriber that handles the execution of events in a separate task to not
330 330 block the execution of the code which triggers the event. It puts the
331 331 received events into a queue from which the worker process takes them in
332 332 order.
333 333 """
334 334 def __init__(self):
335 335 self._stop = False
336 336 self._eventq = queue.Queue()
337 337 self._worker = self.create_worker()
338 338 self._worker.start()
339 339
340 340 def __call__(self, event):
341 341 self._eventq.put(event)
342 342
343 343 def create_worker(self):
344 344 worker = Thread(target=self.do_work)
345 345 worker.daemon = True
346 346 return worker
347 347
348 348 def stop_worker(self):
349 349 self._stop = False
350 350 self._eventq.put(None)
351 351 self._worker.join()
352 352
353 353 def do_work(self):
354 354 while not self._stop:
355 355 event = self._eventq.get()
356 356 if event is not None:
357 357 self.run(event)
358 358
359 359
360 360 class AsyncSubprocessSubscriber(AsyncSubscriber):
361 361 """
362 362 Subscriber that uses the subprocess module to execute a command if an
363 363 event is received. Events are handled asynchronously::
364 364
365 365 subscriber = AsyncSubprocessSubscriber('ls -la', timeout=10)
366 366 subscriber(dummyEvent) # running __call__(event)
367 367
368 368 """
369 369
370 370 def __init__(self, cmd, timeout=None):
371 371 if not isinstance(cmd, (list, tuple)):
372 372 cmd = shlex.split(cmd)
373 373 super(AsyncSubprocessSubscriber, self).__init__()
374 374 self._cmd = cmd
375 375 self._timeout = timeout
376 376
377 377 def run(self, event):
378 378 cmd = self._cmd
379 379 timeout = self._timeout
380 380 log.debug('Executing command %s.', cmd)
381 381
382 382 try:
383 383 output = subprocess.check_output(
384 384 cmd, timeout=timeout, stderr=subprocess.STDOUT)
385 385 log.debug('Command finished %s', cmd)
386 386 if output:
387 387 log.debug('Command output: %s', output)
388 388 except subprocess.TimeoutExpired as e:
389 389 log.exception('Timeout while executing command.')
390 390 if e.output:
391 391 log.error('Command output: %s', e.output)
392 392 except subprocess.CalledProcessError as e:
393 393 log.exception('Error while executing command.')
394 394 if e.output:
395 395 log.error('Command output: %s', e.output)
396 396 except Exception:
397 397 log.exception(
398 398 'Exception while executing command %s.', cmd)
@@ -1,115 +1,115 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="/base/base.mako"/>
3 3 <%namespace name="base" file="/base/base.mako"/>
4 4
5 5 <%def name="title()">
6 6 ${_('Admin audit log entry')}
7 7 %if c.rhodecode_name:
8 8 &middot; ${h.branding(c.rhodecode_name)}
9 9 %endif
10 10 </%def>
11 11
12 12 <%def name="breadcrumbs_links()"></%def>
13 13
14 14 <%def name="menu_bar_nav()">
15 15 ${self.menu_items(active='admin')}
16 16 </%def>
17 17
18 18 <%def name="menu_bar_subnav()">
19 19 ${self.admin_menu(active='audit_logs')}
20 20 </%def>
21 21
22 22 <%def name="main()">
23 23 <div class="box">
24 24
25 25 <div class="title">
26 26 ${_('Audit long entry')} ${c.audit_log_entry.entry_id}
27 27 </div>
28 28
29 29 <div class="table">
30 30 <div id="user_log">
31 31 <table class="rctable audit-log">
32 32 <tr>
33 33 <td>
34 34 ${_('User')}:
35 35 </td>
36 36 <td>
37 37 %if c.audit_log_entry.user is not None:
38 38 ${base.gravatar_with_user(c.audit_log_entry.user.email)}
39 39 %else:
40 40 ${c.audit_log_entry.username}
41 41 %endif
42 42 </td>
43 43 </tr>
44 44 <tr>
45 45 <td>
46 46 ${_('Date')}:
47 47 </td>
48 48 <td>
49 49 ${h.format_date(c.audit_log_entry.action_date)}
50 50 </td>
51 51 </tr>
52 52 <tr>
53 53 <td>
54 54 ${_('IP')}:
55 55 </td>
56 56 <td>
57 57 ${c.audit_log_entry.user_ip}
58 58 </td>
59 59 </tr>
60 60
61 61 <tr>
62 62 <td>
63 63 ${_('Action')}:
64 64 </td>
65 65 <td>
66 66 % if c.audit_log_entry.version == c.audit_log_entry.VERSION_1:
67 67 ${h.action_parser(request, l)[0]()}
68 68 % else:
69 69 ${h.literal(c.audit_log_entry.action)}
70 70 % endif
71 71
72 72 <div class="journal_action_params">
73 73 % if c.audit_log_entry.version == c.audit_log_entry.VERSION_1:
74 74 ${h.literal(h.action_parser(request, l)[1]())}
75 75 % endif
76 76 </div>
77 77 </td>
78 78 </tr>
79 79 <tr>
80 80 <td>
81 81 ${_('Action Data')}:
82 82 </td>
83 83 <td class="td-journalaction">
84 84 % if c.audit_log_entry.version == c.audit_log_entry.VERSION_2:
85 85 <div>
86 <pre>${h.json.dumps(c.audit_log_entry.action_data, indent=4, sort_keys=True)}</pre>
86 <pre>${h.formatted_str_json(c.audit_log_entry.action_data)}</pre>
87 87 </div>
88 88 % else:
89 89 <pre title="${_('data not available for v1 entries type')}">-</pre>
90 90 % endif
91 91 </td>
92 92 </tr>
93 93 <tr>
94 94 <td>
95 95 ${_('Repository')}:
96 96 </td>
97 97 <td class="td-journalaction">
98 98 %if c.audit_log_entry.repository is not None:
99 99 ${h.link_to(c.audit_log_entry.repository.repo_name, h.route_path('repo_summary',repo_name=c.audit_log_entry.repository.repo_name))}
100 100 %else:
101 101 ${c.audit_log_entry.repository_name or '-'}
102 102 %endif
103 103 </td>
104 104 </tr>
105 105
106 106 </table>
107 107
108 108 </div>
109 109 </div>
110 110 </div>
111 111
112 112 <script>
113 113 $('#j_filter').autoGrowInput();
114 114 </script>
115 115 </%def>
@@ -1,69 +1,69 b''
1 1 <%namespace name="base" file="/base/base.mako"/>
2 2
3 3 %if c.audit_logs:
4 4 <table class="rctable admin_log">
5 5 <tr>
6 6 <th>${_('Uid')}</th>
7 7 <th>${_('Username')}</th>
8 8 <th>${_('Action')}</th>
9 9 <th>${_('Action Data')}</th>
10 10 <th>${_('Repository')}</th>
11 11 <th>${_('Date')}</th>
12 12 <th>${_('IP')}</th>
13 13 </tr>
14 14
15 15 %for cnt,l in enumerate(c.audit_logs):
16 16 <tr class="parity${cnt%2}">
17 17 <td class="td-col">
18 18 <a href="${h.route_path('admin_audit_log_entry', audit_log_id=l.entry_id)}">${l.entry_id}</a>
19 19 </td>
20 20 <td class="td-user">
21 21 %if l.user is not None:
22 22 ${base.gravatar_with_user(l.user.email)}
23 23 %else:
24 24 ${l.username}
25 25 %endif
26 26 </td>
27 27 <td class="td-journalaction">
28 28 % if l.version == l.VERSION_1:
29 29 ${h.action_parser(request, l)[0]()}
30 30 % else:
31 31 ${h.literal(l.action)}
32 32 % endif
33 33
34 34 <div class="journal_action_params">
35 35 % if l.version == l.VERSION_1:
36 36 ${h.literal(h.action_parser(request, l)[1]())}
37 37 % endif
38 38 </div>
39 39 </td>
40 40 <td>
41 41 % if l.version == l.VERSION_2:
42 42 <a href="#" onclick="$('#entry-'+${l.user_log_id}).toggle();return false">${_('toggle')}</a>
43 43 <div id="entry-${l.user_log_id}" style="display: none">
44 <pre>${h.json.dumps(l.action_data, indent=4, sort_keys=True)}</pre>
44 <pre>${h.formatted_str_json(l.action_data)}</pre>
45 45 </div>
46 46 % else:
47 47 <pre title="${_('data not available for v1 entries type')}">-</pre>
48 48 % endif
49 49 </td>
50 50 <td class="td-componentname">
51 51 %if l.repository is not None:
52 52 ${h.link_to(l.repository.repo_name, h.route_path('repo_summary',repo_name=l.repository.repo_name))}
53 53 %else:
54 54 ${l.repository_name}
55 55 %endif
56 56 </td>
57 57
58 58 <td class="td-time">${h.format_date(l.action_date)}</td>
59 59 <td class="td-ip">${l.user_ip}</td>
60 60 </tr>
61 61 %endfor
62 62 </table>
63 63
64 64 <div class="pagination-wh pagination-left">
65 65 ${c.audit_logs.render()}
66 66 </div>
67 67 %else:
68 68 ${_('No actions yet')}
69 69 %endif No newline at end of file
@@ -1,328 +1,328 b''
1 1 ## snippet for displaying issue tracker settings
2 2 ## usage:
3 3 ## <%namespace name="its" file="/base/issue_tracker_settings.mako"/>
4 4 ## ${its.issue_tracker_settings_table(patterns, form_url, delete_url)}
5 5 ## ${its.issue_tracker_settings_test(test_url)}
6 6
7 7 <%def name="issue_tracker_settings_table(patterns, form_url, delete_url)">
8 8 <%
9 9 # Name/desc, pattern, issue prefix
10 10 examples = [
11 11 (
12 12 ' ',
13 13 ' ',
14 14 ' ',
15 15 ' '
16 16 ),
17 17
18 18 (
19 19 'Tickets with #123 (Redmine etc)',
20 20 '(?<![a-zA-Z0-9_/]{1,10}-?)(#)(?P<issue_id>\d+)',
21 21 'https://myissueserver.com/${repo}/issue/${issue_id}',
22 22 ''
23 23 ),
24 24
25 25 (
26 26 'Redmine - Alternative',
27 27 '(?:issue-)(\d+)',
28 28 'https://myissueserver.com/redmine/issue/${id}',
29 29 ''
30 30 ),
31 31
32 32 (
33 33 'Redmine - Wiki',
34 34 '(?:wiki-)([a-zA-Z0-9]+)',
35 35 'http://example.org/projects/${repo_name}/wiki/${id}',
36 36 'wiki-'
37 37 ),
38 38
39 39 (
40 40 'JIRA - All tickets',
41 41 # official JIRA ticket pattern
42 42 '(?<![a-zA-Z0-9_/#]-?)(?P<issue_id>[A-Z]{1,6}-(?:[1-9][0-9]{0,7}))',
43 43 'https://myjira.com/browse/${issue_id}',
44 44 ''
45 45 ),
46 46
47 47 (
48 48 'JIRA - Single project (JRA-XXXXXXXX)',
49 49 '(?<![a-zA-Z0-9_/#]-?)(?P<issue_id>JRA-(?:[1-9][0-9]{0,7}))',
50 50 'https://myjira.com/${issue_id}',
51 51 ''
52 52 ),
53 53
54 54 (
55 55 'Confluence WIKI',
56 56 '(?:conf-)([A-Z0-9]+)',
57 57 'https://example.atlassian.net/display/wiki/${id}/${repo_name}',
58 58 'CONF-',
59 59 ),
60 60
61 61 (
62 62 'Pivotal Tracker',
63 63 '(?:pivot-)(?P<project_id>\d+)-(?P<story>\d+)',
64 64 'https://www.pivotaltracker.com/s/projects/${project_id}/stories/${story}',
65 65 'PIV-',
66 66 ),
67 67
68 68 (
69 69 'Trello',
70 70 '(?:trello-)(?P<card_id>[a-zA-Z0-9]+)',
71 71 'https://trello.com/example.com/${card_id}',
72 72 'TRELLO-',
73 73 ),
74 74 ]
75 75 %>
76 76
77 77 <table class="rctable issuetracker">
78 78 <tr>
79 79 <th>${_('Description')}</th>
80 80 <th>${_('Pattern')}</th>
81 81 <th>${_('Url')}</th>
82 82 <th>${_('Extra Prefix')}</th>
83 83 <th ></th>
84 84 </tr>
85 85 % for name, pat, url, pref in examples:
86 86 <tr class="it-examples" style="${'' if loop.index == 0 else 'display:none'}">
87 87 <td class="td-issue-tracker-name issue-tracker-example">${name}</td>
88 88 <td class="td-regex issue-tracker-example">${pat}</td>
89 89 <td class="td-url issue-tracker-example">${url}</td>
90 90 <td class="td-prefix issue-tracker-example">${pref}</td>
91 91 <td>
92 92 % if loop.index == 0:
93 93 <a href="#showMore" onclick="$('.it-examples').toggle(); return false">${_('show examples')}</a>
94 94 % else:
95 <a href="#copyToInput" onclick="copyToInput(this, '${h.json.dumps(name)}', '${h.json.dumps(pat)}', '${h.json.dumps(url)}', '${h.json.dumps(pref)}'); return false">copy to input</a>
95 <a href="#copyToInput" onclick="copyToInput(this, '${h.str_json(name)}', '${h.str_json(pat)}', '${h.str_json(url)}', '${h.str_json(pref)}'); return false">copy to input</a>
96 96 % endif
97 97 </td>
98 98 </tr>
99 99 % endfor
100 100
101 101 %for uid, entry in patterns:
102 102 <tr id="entry_${uid}">
103 103 <td class="td-issue-tracker-name issuetracker_desc">
104 104 <span class="entry">
105 105 ${entry.desc}
106 106 </span>
107 107 <span class="edit">
108 108 ${h.text('new_pattern_description_'+uid, class_='medium-inline', value=entry.desc or '')}
109 109 </span>
110 110 </td>
111 111 <td class="td-issue-tracker-regex issuetracker_pat">
112 112 <span class="entry">
113 113 ${entry.pat}
114 114 </span>
115 115 <span class="edit">
116 116 ${h.text('new_pattern_pattern_'+uid, class_='medium-inline', value=entry.pat or '')}
117 117 </span>
118 118 </td>
119 119 <td class="td-url issuetracker_url">
120 120 <span class="entry">
121 121 ${entry.url}
122 122 </span>
123 123 <span class="edit">
124 124 ${h.text('new_pattern_url_'+uid, class_='medium-inline', value=entry.url or '')}
125 125 </span>
126 126 </td>
127 127 <td class="td-prefix issuetracker_pref">
128 128 <span class="entry">
129 129 ${entry.pref}
130 130 </span>
131 131 <span class="edit">
132 132 ${h.text('new_pattern_prefix_'+uid, class_='medium-inline', value=entry.pref or '')}
133 133 </span>
134 134 </td>
135 135 <td class="td-action">
136 136 <div class="grid_edit">
137 137 <span class="entry">
138 138 <a class="edit_issuetracker_entry" href="">${_('Edit')}</a>
139 139 </span>
140 140 <span class="edit">
141 141 <input id="uid_${uid}" name="uid" type="hidden" value="${uid}">
142 142 </span>
143 143 </div>
144 144 <div class="grid_delete">
145 145 <span class="entry">
146 146 <a class="btn btn-link btn-danger delete_issuetracker_entry" data-desc="${entry.desc}" data-uid="${uid}">
147 147 ${_('Delete')}
148 148 </a>
149 149 </span>
150 150 <span class="edit">
151 151 <a class="btn btn-link btn-danger edit_issuetracker_cancel" data-uid="${uid}">${_('Cancel')}</a>
152 152 </span>
153 153 </div>
154 154 </td>
155 155 </tr>
156 156 %endfor
157 157 <tr id="last-row"></tr>
158 158 </table>
159 159 <p>
160 160 <a id="add_pattern" class="link">
161 161 ${_('Add new')}
162 162 </a>
163 163 </p>
164 164
165 165 <script type="text/javascript">
166 166 var newEntryLabel = $('label[for="new_entry"]');
167 167
168 168 var resetEntry = function() {
169 169 newEntryLabel.text("${_('New Entry')}:");
170 170 };
171 171
172 172 var delete_pattern = function(entry) {
173 173 if (confirm("${_('Confirm to remove this pattern:')} "+$(entry).data('desc'))) {
174 174 $.ajax({
175 175 type: "POST",
176 176 url: "${delete_url}",
177 177 data: {
178 178 'csrf_token': CSRF_TOKEN,
179 179 'uid':$(entry).data('uid')
180 180 },
181 181 success: function(){
182 182 window.location.reload();
183 183 },
184 184 error: function(data, textStatus, errorThrown){
185 185 alert("Error while deleting entry.\nError code {0} ({1}). URL: {2}".format(data.status,data.statusText,$(entry)[0].url));
186 186 }
187 187 });
188 188 }
189 189 };
190 190
191 191 $('.delete_issuetracker_entry').on('click', function(e){
192 192 e.preventDefault();
193 193 delete_pattern(this);
194 194 });
195 195
196 196 $('.edit_issuetracker_entry').on('click', function(e){
197 197 e.preventDefault();
198 198 $(this).parents('tr').addClass('editopen');
199 199 });
200 200
201 201 $('.edit_issuetracker_cancel').on('click', function(e){
202 202 e.preventDefault();
203 203 $(this).parents('tr').removeClass('editopen');
204 204 // Reset to original value
205 205 var uid = $(this).data('uid');
206 206 $('#'+uid+' input').each(function(e) {
207 207 this.value = this.defaultValue;
208 208 });
209 209 });
210 210
211 211 $('input#reset').on('click', function(e) {
212 212 resetEntry();
213 213 });
214 214
215 215 $('#add_pattern').on('click', function(e) {
216 216 addNewPatternInput();
217 217 });
218 218
219 219 var copied = false;
220 220 copyToInput = function (elem, name, pat, url, pref) {
221 221 if (copied === false) {
222 222 addNewPatternInput();
223 223 copied = true;
224 224 }
225 225 $(elem).hide();
226 226 var load = function(text){
227 227 return text.replace(/["]/g, "")
228 228 };
229 229 $('#description_1').val(load(name));
230 230 $('#pattern_1').val(load(pat));
231 231 $('#url_1').val(load(url));
232 232 $('#prefix_1').val(load(pref));
233 233
234 234 }
235 235
236 236 </script>
237 237 </%def>
238 238
239 239 <%def name="issue_tracker_new_row()">
240 240 <table id="add-row-tmpl" style="display: none;">
241 241 <tbody>
242 242 <tr class="new_pattern">
243 243 <td class="td-issue-tracker-name issuetracker_desc">
244 244 <span class="entry">
245 245 <input class="medium-inline" id="description_##UUID##" name="new_pattern_description_##UUID##" value="##DESCRIPTION##" type="text">
246 246 </span>
247 247 </td>
248 248 <td class="td-issue-tracker-regex issuetracker_pat">
249 249 <span class="entry">
250 250 <input class="medium-inline" id="pattern_##UUID##" name="new_pattern_pattern_##UUID##" placeholder="Pattern"
251 251 value="##PATTERN##" type="text">
252 252 </span>
253 253 </td>
254 254 <td class="td-url issuetracker_url">
255 255 <span class="entry">
256 256 <input class="medium-inline" id="url_##UUID##" name="new_pattern_url_##UUID##" placeholder="Url" value="##URL##" type="text">
257 257 </span>
258 258 </td>
259 259 <td class="td-prefix issuetracker_pref">
260 260 <span class="entry">
261 261 <input class="medium-inline" id="prefix_##UUID##" name="new_pattern_prefix_##UUID##" placeholder="Prefix" value="##PREFIX##" type="text">
262 262 </span>
263 263 </td>
264 264 <td class="td-action">
265 265 </td>
266 266 <input id="uid_##UUID##" name="uid_##UUID##" type="hidden" value="">
267 267 </tr>
268 268 </tbody>
269 269 </table>
270 270 </%def>
271 271
272 272 <%def name="issue_tracker_settings_test(test_url)">
273 273 <div class="form-vertical">
274 274 <div class="fields">
275 275 <div class="field">
276 276 <div class='textarea-full'>
277 277 <textarea id="test_pattern_data" rows="12">
278 278 This is an example text for testing issue tracker patterns.
279 279 This commit fixes ticket #451 and ticket #910, reference for JRA-401.
280 280 The following tickets will get mentioned:
281 281 #123
282 282 #456 and PROJ-101
283 283 JRA-123 and #123
284 284 PROJ-456
285 285
286 286 [my artifact](http://something.com/JRA-1234-build.zip)
287 287
288 288 - #1001
289 289 - JRA-998
290 290
291 291 Open a pull request !101 to contribute!
292 292 Added tag v1.3.0 for commit 0f3b629be725
293 293
294 294 Add a test pattern here and hit preview to see the link.
295 295 </textarea>
296 296 </div>
297 297 </div>
298 298 </div>
299 299 <div class="test_pattern_preview">
300 300 <div id="test_pattern" class="btn btn-small" >${_('Preview')}</div>
301 301 <p>${_('Test Pattern Preview')}</p>
302 302 <div id="test_pattern_result" style="white-space: pre-wrap"></div>
303 303 </div>
304 304 </div>
305 305
306 306 <script type="text/javascript">
307 307 $('#test_pattern').on('click', function(e) {
308 308 $.ajax({
309 309 type: "POST",
310 310 url: "${test_url}",
311 311 data: {
312 312 'test_text': $('#test_pattern_data').val(),
313 313 'csrf_token': CSRF_TOKEN
314 314 },
315 315 success: function(data){
316 316 $('#test_pattern_result').html(data);
317 317 tooltipActivate();
318 318 },
319 319 error: function(jqXHR, textStatus, errorThrown){
320 320 $('#test_pattern_result').html('Error: ' + errorThrown);
321 321 }
322 322 });
323 323 $('#test_pattern_result').show();
324 324 });
325 325 </script>
326 326 </%def>
327 327
328 328
@@ -1,166 +1,166 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <!DOCTYPE html>
3 3
4 4 <%
5 5 c.template_context['repo_name'] = getattr(c, 'repo_name', '')
6 6 go_import_header = ''
7 7 if hasattr(c, 'rhodecode_db_repo'):
8 8 c.template_context['repo_type'] = c.rhodecode_db_repo.repo_type
9 9 c.template_context['repo_landing_commit'] = c.rhodecode_db_repo.landing_ref_name
10 10 c.template_context['repo_id'] = c.rhodecode_db_repo.repo_id
11 11 c.template_context['repo_view_type'] = h.get_repo_view_type(request)
12 12
13 13 if getattr(c, 'repo_group', None):
14 14 c.template_context['repo_group_id'] = c.repo_group.group_id
15 15 c.template_context['repo_group_name'] = c.repo_group.group_name
16 16
17 17 if getattr(c, 'rhodecode_user', None) and c.rhodecode_user.user_id:
18 18 c.template_context['rhodecode_user']['user_id'] = c.rhodecode_user.user_id
19 19 c.template_context['rhodecode_user']['username'] = c.rhodecode_user.username
20 20 c.template_context['rhodecode_user']['email'] = c.rhodecode_user.email
21 21 c.template_context['rhodecode_user']['notification_status'] = c.rhodecode_user.get_instance().user_data.get('notification_status', True)
22 22 c.template_context['rhodecode_user']['first_name'] = c.rhodecode_user.first_name
23 23 c.template_context['rhodecode_user']['last_name'] = c.rhodecode_user.last_name
24 24
25 25 c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer')
26 26 c.template_context['default_user'] = {
27 27 'username': h.DEFAULT_USER,
28 28 'user_id': 1
29 29 }
30 30 c.template_context['search_context'] = {
31 31 'repo_group_id': c.template_context.get('repo_group_id'),
32 32 'repo_group_name': c.template_context.get('repo_group_name'),
33 33 'repo_id': c.template_context.get('repo_id'),
34 34 'repo_name': c.template_context.get('repo_name'),
35 35 'repo_view_type': c.template_context.get('repo_view_type'),
36 36 }
37 37
38 38 c.template_context['attachment_store'] = {
39 39 'max_file_size_mb': 10,
40 40 'image_ext': ["png", "jpg", "gif", "jpeg"]
41 41 }
42 42
43 43 %>
44 44 <html xmlns="http://www.w3.org/1999/xhtml">
45 45 <head>
46 46 <title>${self.title()}</title>
47 47 <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
48 48
49 49 ${h.go_import_header(request, getattr(c, 'rhodecode_db_repo', None))}
50 50
51 51 % if 'safari' in (request.user_agent or '').lower():
52 52 <meta name="referrer" content="origin">
53 53 % else:
54 54 <meta name="referrer" content="origin-when-cross-origin">
55 55 % endif
56 56
57 57 <%def name="robots()">
58 58 <meta name="robots" content="index, nofollow"/>
59 59 </%def>
60 60 ${self.robots()}
61 61 <link rel="icon" href="${h.asset('images/favicon.ico', ver=c.rhodecode_version_hash)}" sizes="16x16 32x32" type="image/png" />
62 62 <script src="${h.asset('js/vendors/webcomponentsjs/custom-elements-es5-adapter.js', ver=c.rhodecode_version_hash)}"></script>
63 63 <script src="${h.asset('js/vendors/webcomponentsjs/webcomponents-bundle.js', ver=c.rhodecode_version_hash)}"></script>
64 64
65 65 ## CSS definitions
66 66 <%def name="css()">
67 67 <link rel="stylesheet" type="text/css" href="${h.asset('css/style.css', ver=c.rhodecode_version_hash)}" media="screen"/>
68 68 ## EXTRA FOR CSS
69 69 ${self.css_extra()}
70 70 </%def>
71 71 ## CSS EXTRA - optionally inject some extra CSS stuff needed for specific websites
72 72 <%def name="css_extra()">
73 73 </%def>
74 74
75 75 ${self.css()}
76 76
77 77 ## JAVASCRIPT
78 78 <%def name="js()">
79 79
80 80 <script src="${h.asset('js/rhodecode/i18n/%s.js' % c.language, ver=c.rhodecode_version_hash)}"></script>
81 81 <script type="text/javascript">
82 82 // register templateContext to pass template variables to JS
83 var templateContext = ${h.json.dumps(c.template_context)|n};
83 var templateContext = ${h.str_json(c.template_context)|n};
84 84
85 85 var APPLICATION_URL = "${h.route_path('home').rstrip('/')}";
86 86 var APPLICATION_PLUGINS = [];
87 87 var ASSET_URL = "${h.asset('')}";
88 88 var DEFAULT_RENDERER = "${h.get_visual_attr(c, 'default_renderer')}";
89 89 var CSRF_TOKEN = "${getattr(c, 'csrf_token', '')}";
90 90
91 91 var APPENLIGHT = {
92 92 enabled: ${'true' if getattr(c, 'appenlight_enabled', False) else 'false'},
93 93 key: '${getattr(c, "appenlight_api_public_key", "")}',
94 94 % if getattr(c, 'appenlight_server_url', None):
95 95 serverUrl: '${getattr(c, "appenlight_server_url", "")}',
96 96 % endif
97 97 requestInfo: {
98 98 % if getattr(c, 'rhodecode_user', None):
99 99 ip: '${c.rhodecode_user.ip_addr}',
100 100 username: '${c.rhodecode_user.username}'
101 101 % endif
102 102 },
103 103 tags: {
104 104 rhodecode_version: '${c.rhodecode_version}',
105 105 rhodecode_edition: '${c.rhodecode_edition}'
106 106 }
107 107 };
108 108
109 109 </script>
110 110 <%include file="/base/plugins_base.mako"/>
111 111 <!--[if lt IE 9]>
112 112 <script language="javascript" type="text/javascript" src="${h.asset('js/src/excanvas.min.js')}"></script>
113 113 <![endif]-->
114 114 <script language="javascript" type="text/javascript" src="${h.asset('js/rhodecode/routes.js', ver=c.rhodecode_version_hash)}"></script>
115 115 <script> var alertMessagePayloads = ${h.flash.json_alerts(request=request)|n}; </script>
116 116 ## avoide escaping the %N
117 117 <script language="javascript" type="text/javascript" src="${h.asset('js/scripts.min.js', ver=c.rhodecode_version_hash)}"></script>
118 118 <script>CodeMirror.modeURL = "${h.asset('') + 'js/mode/%N/%N.js?ver='+c.rhodecode_version_hash}";</script>
119 119
120 120
121 121 ## JAVASCRIPT EXTRA - optionally inject some extra JS for specificed templates
122 122 ${self.js_extra()}
123 123
124 124 <script type="text/javascript">
125 125 Rhodecode = (function() {
126 126 function _Rhodecode() {
127 127 this.comments = new CommentsController();
128 128 }
129 129 return new _Rhodecode();
130 130 })();
131 131
132 132 $(document).ready(function(){
133 133 show_more_event();
134 134 timeagoActivate();
135 135 tooltipActivate();
136 136 clipboardActivate();
137 137 })
138 138 </script>
139 139
140 140 </%def>
141 141
142 142 ## JAVASCRIPT EXTRA - optionally inject some extra JS for specificed templates
143 143 <%def name="js_extra()"></%def>
144 144 ${self.js()}
145 145
146 146 <%def name="head_extra()"></%def>
147 147 ${self.head_extra()}
148 148 ## extra stuff
149 149 %if c.pre_code:
150 150 ${c.pre_code|n}
151 151 %endif
152 152 </head>
153 153 <body id="body">
154 154 <noscript>
155 155 <div class="noscript-error">
156 156 ${_('Please enable JavaScript to use RhodeCode Enterprise')}
157 157 </div>
158 158 </noscript>
159 159
160 160 ${next.body()}
161 161 %if c.post_code:
162 162 ${c.post_code|n}
163 163 %endif
164 164 <rhodecode-app></rhodecode-app>
165 165 </body>
166 166 </html>
@@ -1,168 +1,168 b''
1 1 ## snippet for sidebar elements
2 2 ## usage:
3 3 ## <%namespace name="sidebar" file="/base/sidebar.mako"/>
4 4 ## ${sidebar.comments_table()}
5 5 <%namespace name="base" file="/base/base.mako"/>
6 6
7 7 <%def name="comments_table(comments, counter_num, todo_comments=False, draft_comments=False, existing_ids=None, is_pr=True)">
8 8 <%
9 9 if todo_comments:
10 10 cls_ = 'todos-content-table'
11 11 def sorter(entry):
12 12 user_id = entry.author.user_id
13 13 resolved = '1' if entry.resolved else '0'
14 14 if user_id == c.rhodecode_user.user_id:
15 15 # own comments first
16 16 user_id = 0
17 17 return '{}'.format(str(entry.comment_id).zfill(10000))
18 18 elif draft_comments:
19 19 cls_ = 'drafts-content-table'
20 20 def sorter(entry):
21 21 return '{}'.format(str(entry.comment_id).zfill(10000))
22 22 else:
23 23 cls_ = 'comments-content-table'
24 24 def sorter(entry):
25 25 return '{}'.format(str(entry.comment_id).zfill(10000))
26 26
27 27 existing_ids = existing_ids or []
28 28
29 29 %>
30 30
31 31 <table class="todo-table ${cls_}" data-total-count="${len(comments)}" data-counter="${counter_num}">
32 32
33 33 % for loop_obj, comment_obj in h.looper(reversed(sorted(comments, key=sorter))):
34 34 <%
35 35 display = ''
36 36 _cls = ''
37 37 ## Extra precaution to not show drafts in the sidebar for todo/comments
38 38 if comment_obj.draft and not draft_comments:
39 39 continue
40 40 %>
41 41
42 42
43 43 <%
44 44 comment_ver_index = comment_obj.get_index_version(getattr(c, 'versions', []))
45 45 prev_comment_ver_index = 0
46 46 if loop_obj.previous:
47 47 prev_comment_ver_index = loop_obj.previous.get_index_version(getattr(c, 'versions', []))
48 48
49 49 ver_info = None
50 50 if getattr(c, 'versions', []):
51 51 ver_info = c.versions[comment_ver_index-1] if comment_ver_index else None
52 52 %>
53 53 <% hidden_at_ver = comment_obj.outdated_at_version_js(c.at_version_num) %>
54 54 <% is_from_old_ver = comment_obj.older_than_version_js(c.at_version_num) %>
55 55 <%
56 56 if (prev_comment_ver_index > comment_ver_index):
57 57 comments_ver_divider = comment_ver_index
58 58 else:
59 59 comments_ver_divider = None
60 60 %>
61 61
62 62 % if todo_comments:
63 63 % if comment_obj.resolved:
64 64 <% _cls = 'resolved-todo' %>
65 65 <% display = 'none' %>
66 66 % endif
67 67 % else:
68 68 ## SKIP TODOs we display them in other area
69 69 % if comment_obj.is_todo and not comment_obj.draft:
70 70 <% display = 'none' %>
71 71 % endif
72 72 ## Skip outdated comments
73 73 % if comment_obj.outdated:
74 74 <% display = 'none' %>
75 75 <% _cls = 'hidden-comment' %>
76 76 % endif
77 77 % endif
78 78
79 79 % if not todo_comments and comments_ver_divider:
80 80 <tr class="old-comments-marker">
81 81 <td colspan="3">
82 82 % if ver_info:
83 83 <code>v${comments_ver_divider} ${h.age_component(ver_info.created_on, time_is_local=True, tooltip=False)}</code>
84 84 % else:
85 85 <code>v${comments_ver_divider}</code>
86 86 % endif
87 87 </td>
88 88 </tr>
89 89
90 90 % endif
91 91
92 92 <tr class="${_cls}" style="display: ${display};" data-sidebar-comment-id="${comment_obj.comment_id}">
93 93 % if draft_comments:
94 94 <td style="width: 15px;">
95 95 ${h.checkbox('submit_draft', id=None, value=comment_obj.comment_id)}
96 96 </td>
97 97 % endif
98 98 <td class="td-todo-number">
99 99 <%
100 100 version_info = ''
101 101 if is_pr:
102 102 version_info = (' made in older version (v{})'.format(comment_ver_index) if is_from_old_ver == 'true' else ' made in this version')
103 103 %>
104 104 ## new comments, since refresh
105 105 % if existing_ids and comment_obj.comment_id not in existing_ids:
106 106 <div class="tooltip" style="position: absolute; left: 8px; color: #682668" title="New comment">
107 107 !
108 108 </div>
109 109 % endif
110 110
111 111 <%
112 data = h.json.dumps({
112 data = h.str_json({
113 113 'comment_id': comment_obj.comment_id,
114 114 'version_info': version_info,
115 115 'file_name': comment_obj.f_path,
116 116 'line_no': comment_obj.line_no,
117 117 'outdated': comment_obj.outdated,
118 118 'inline': comment_obj.is_inline,
119 119 'is_todo': comment_obj.is_todo,
120 120 'created_on': h.format_date(comment_obj.created_on),
121 121 'datetime': '{}{}'.format(comment_obj.created_on, h.get_timezone(comment_obj.created_on, time_is_local=True)),
122 122 'review_status': (comment_obj.review_status or '')
123 123 })
124 124
125 125 icon = ''
126 126
127 127 if comment_obj.outdated:
128 128 icon += ' icon-comment-toggle'
129 129 elif comment_obj.is_inline:
130 130 icon += ' icon-code'
131 131 else:
132 132 icon += ' icon-comment'
133 133
134 134 if comment_obj.draft:
135 135 if comment_obj.is_todo:
136 136 icon = 'icon-flag-filled icon-draft'
137 137 else:
138 138 icon = 'icon-comment icon-draft'
139 139
140 140 %>
141 141
142 142 <i id="commentHovercard${comment_obj.comment_id}"
143 143 class="${icon} tooltip-hovercard"
144 144 data-hovercard-url="javascript:sidebarComment(${comment_obj.comment_id})"
145 145 data-comment-json-b64='${h.b64(data)}'>
146 146 </i>
147 147
148 148 </td>
149 149
150 150 <td class="td-todo-gravatar">
151 151 ${base.gravatar(comment_obj.author.email, 16, user=comment_obj.author, tooltip=True, extra_class=['no-margin'])}
152 152 </td>
153 153 <td class="todo-comment-text-wrapper">
154 154 <div class="todo-comment-text ${('todo-resolved' if comment_obj.resolved else '')}">
155 155 <a class="${('todo-resolved' if comment_obj.resolved else '')} permalink"
156 156 href="#comment-${comment_obj.comment_id}"
157 157 onclick="return Rhodecode.comments.scrollToComment($('#comment-${comment_obj.comment_id}'), 0, ${hidden_at_ver})">
158 158
159 159 ${h.chop_at_smart(comment_obj.text, '\n', suffix_if_chopped='...')}
160 160 </a>
161 161 </div>
162 162 </td>
163 163 </tr>
164 164 % endfor
165 165
166 166 </table>
167 167
168 168 </%def> No newline at end of file
@@ -1,557 +1,557 b''
1 1 ## -*- coding: utf-8 -*-
2 2 ## usage:
3 3 ## <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
4 4 ## ${comment.comment_block(comment)}
5 5 ##
6 6 <%namespace name="base" file="/base/base.mako"/>
7 7
8 8 <%!
9 9 from rhodecode.lib import html_filters
10 10 %>
11 11
12 12
13 13 <%def name="comment_block(comment, inline=False, active_pattern_entries=None, is_new=False)">
14 14
15 15 <%
16 16 from rhodecode.model.comment import CommentsModel
17 17 comment_model = CommentsModel()
18 18
19 19 comment_ver = comment.get_index_version(getattr(c, 'versions', []))
20 20 latest_ver = len(getattr(c, 'versions', []))
21 21 visible_for_user = True
22 22 if comment.draft:
23 23 visible_for_user = comment.user_id == c.rhodecode_user.user_id
24 24 %>
25 25
26 26 % if inline:
27 27 <% outdated_at_ver = comment.outdated_at_version(c.at_version_num) %>
28 28 % else:
29 29 <% outdated_at_ver = comment.older_than_version(c.at_version_num) %>
30 30 % endif
31 31
32 32 % if visible_for_user:
33 33 <div class="comment
34 34 ${'comment-inline' if inline else 'comment-general'}
35 35 ${'comment-outdated' if outdated_at_ver else 'comment-current'}"
36 36 id="comment-${comment.comment_id}"
37 37 line="${comment.line_no}"
38 38 data-comment-id="${comment.comment_id}"
39 39 data-comment-type="${comment.comment_type}"
40 data-comment-draft=${h.json.dumps(comment.draft)}
40 data-comment-draft=${h.str_json(comment.draft)}
41 41 data-comment-renderer="${comment.renderer}"
42 42 data-comment-text="${comment.text | html_filters.base64,n}"
43 43 data-comment-f-path="${comment.f_path}"
44 44 data-comment-line-no="${comment.line_no}"
45 data-comment-inline=${h.json.dumps(inline)}
45 data-comment-inline=${h.str_json(inline)}
46 46 style="${'display: none;' if outdated_at_ver else ''}">
47 47
48 48 <div class="meta">
49 49 <div class="comment-type-label">
50 50 % if comment.draft:
51 51 <div class="tooltip comment-draft" title="${_('Draft comments are only visible to the author until submitted')}.">
52 52 DRAFT
53 53 </div>
54 54 % elif is_new:
55 55 <div class="tooltip comment-new" title="${_('This comment was added while you browsed this page')}.">
56 56 NEW
57 57 </div>
58 58 % endif
59 59
60 60 <div class="comment-label ${comment.comment_type or 'note'}" id="comment-label-${comment.comment_id}">
61 61
62 62 ## TODO COMMENT
63 63 % if comment.comment_type == 'todo':
64 64 % if comment.resolved:
65 65 <div class="resolved tooltip" title="${_('Resolved by comment #{}').format(comment.resolved.comment_id)}">
66 66 <i class="icon-flag-filled"></i>
67 67 <a href="#comment-${comment.resolved.comment_id}">${comment.comment_type}</a>
68 68 </div>
69 69 % else:
70 70 <div class="resolved tooltip" style="display: none">
71 71 <span>${comment.comment_type}</span>
72 72 </div>
73 73 <div class="resolve tooltip" onclick="return Rhodecode.comments.createResolutionComment(${comment.comment_id});" title="${_('Click to create resolution comment.')}">
74 74 <i class="icon-flag-filled"></i>
75 75 ${comment.comment_type}
76 76 </div>
77 77 % endif
78 78 ## NOTE COMMENT
79 79 % else:
80 80 ## RESOLVED NOTE
81 81 % if comment.resolved_comment:
82 82 <div class="tooltip" title="${_('This comment resolves TODO #{}').format(comment.resolved_comment.comment_id)}">
83 83 fix
84 <a href="#comment-${comment.resolved_comment.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${comment.resolved_comment.comment_id}'), 0, ${h.json.dumps(comment.resolved_comment.outdated)})">
84 <a href="#comment-${comment.resolved_comment.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${comment.resolved_comment.comment_id}'), 0, ${h.str_json(comment.resolved_comment.outdated)})">
85 85 <span style="text-decoration: line-through">#${comment.resolved_comment.comment_id}</span>
86 86 </a>
87 87 </div>
88 88 ## STATUS CHANGE NOTE
89 89 % elif not comment.is_inline and comment.status_change:
90 90 <%
91 91 if comment.pull_request:
92 92 status_change_title = 'Status of review for pull request !{}'.format(comment.pull_request.pull_request_id)
93 93 else:
94 94 status_change_title = 'Status of review for commit {}'.format(h.short_id(comment.commit_id))
95 95 %>
96 96
97 97 <i class="icon-circle review-status-${comment.review_status}"></i>
98 98 <div class="changeset-status-lbl tooltip" title="${status_change_title}">
99 99 ${comment.review_status_lbl}
100 100 </div>
101 101 % else:
102 102 <div>
103 103 <i class="icon-comment"></i>
104 104 ${(comment.comment_type or 'note')}
105 105 </div>
106 106 % endif
107 107 % endif
108 108
109 109 </div>
110 110 </div>
111 111 ## NOTE 0 and .. => because we disable it for now until UI ready
112 112 % if 0 and comment.status_change:
113 113 <div class="pull-left">
114 114 <span class="tag authortag tooltip" title="${_('Status from pull request.')}">
115 115 <a href="${h.route_path('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id)}">
116 116 ${'!{}'.format(comment.pull_request.pull_request_id)}
117 117 </a>
118 118 </span>
119 119 </div>
120 120 % endif
121 121 ## Since only author can see drafts, we don't show it
122 122 % if not comment.draft:
123 123 <div class="author ${'author-inline' if inline else 'author-general'}">
124 124 ${base.gravatar_with_user(comment.author.email, 16, tooltip=True)}
125 125 </div>
126 126 % endif
127 127
128 128 <div class="date">
129 129 ${h.age_component(comment.modified_at, time_is_local=True)}
130 130 </div>
131 131
132 132 % if comment.pull_request and comment.pull_request.author.user_id == comment.author.user_id:
133 133 <span class="tag authortag tooltip" title="${_('Pull request author')}">
134 134 ${_('author')}
135 135 </span>
136 136 % endif
137 137
138 138 <%
139 139 comment_version_selector = 'comment_versions_{}'.format(comment.comment_id)
140 140 %>
141 141
142 142 % if comment.history:
143 143 <div class="date">
144 144
145 145 <input id="${comment_version_selector}" name="${comment_version_selector}"
146 146 type="hidden"
147 147 data-last-version="${comment.history[-1].version}">
148 148
149 149 <script type="text/javascript">
150 150
151 151 var preLoadVersionData = [
152 152 % for comment_history in comment.history:
153 153 {
154 154 id: ${comment_history.comment_history_id},
155 155 text: 'v${comment_history.version}',
156 156 action: function () {
157 157 Rhodecode.comments.showVersion(
158 158 "${comment.comment_id}",
159 159 "${comment_history.comment_history_id}"
160 160 )
161 161 },
162 162 comment_version: "${comment_history.version}",
163 163 comment_author_username: "${comment_history.author.username}",
164 164 comment_author_gravatar: "${h.gravatar_url(comment_history.author.email, 16)}",
165 165 comment_created_on: '${h.age_component(comment_history.created_on, time_is_local=True)}',
166 166 },
167 167 % endfor
168 168 ]
169 169 initVersionSelector("#${comment_version_selector}", {results: preLoadVersionData});
170 170
171 171 </script>
172 172
173 173 </div>
174 174 % else:
175 175 <div class="date" style="display: none">
176 176 <input id="${comment_version_selector}" name="${comment_version_selector}"
177 177 type="hidden"
178 178 data-last-version="0">
179 179 </div>
180 180 %endif
181 181
182 182 <div class="comment-links-block">
183 183
184 184 % if inline:
185 185 <a class="pr-version-inline" href="${request.current_route_path(_query=dict(version=comment.pull_request_version_id), _anchor='comment-{}'.format(comment.comment_id))}">
186 186 % if outdated_at_ver:
187 187 <strong class="comment-outdated-label">outdated</strong> <code class="tooltip pr-version-num" title="${_('Outdated comment from pull request version v{0}, latest v{1}').format(comment_ver, latest_ver)}">${'v{}'.format(comment_ver)}</code>
188 188 <code class="action-divider">|</code>
189 189 % elif comment_ver:
190 190 <code class="tooltip pr-version-num" title="${_('Comment from pull request version v{0}, latest v{1}').format(comment_ver, latest_ver)}">${'v{}'.format(comment_ver)}</code>
191 191 <code class="action-divider">|</code>
192 192 % endif
193 193 </a>
194 194 % else:
195 195 % if comment_ver:
196 196
197 197 % if comment.outdated:
198 198 <a class="pr-version"
199 199 href="?version=${comment.pull_request_version_id}#comment-${comment.comment_id}"
200 200 >
201 201 ${_('Outdated comment from pull request version v{0}, latest v{1}').format(comment_ver, latest_ver)}
202 202 </a>
203 203 <code class="action-divider">|</code>
204 204 % else:
205 205 <a class="tooltip pr-version"
206 206 title="${_('Comment from pull request version v{0}, latest v{1}').format(comment_ver, latest_ver)}"
207 207 href="${h.route_path('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id, version=comment.pull_request_version_id)}"
208 208 >
209 209 <code class="pr-version-num">${'v{}'.format(comment_ver)}</code>
210 210 </a>
211 211 <code class="action-divider">|</code>
212 212 % endif
213 213
214 214 % endif
215 215 % endif
216 216
217 217 <details class="details-reset details-inline-block">
218 218 <summary class="noselect"><i class="icon-options cursor-pointer"></i></summary>
219 219 <details-menu class="details-dropdown">
220 220
221 221 <div class="dropdown-item">
222 222 ${_('Comment')} #${comment.comment_id}
223 223 <span class="pull-right icon-clipboard clipboard-action" data-clipboard-text="${comment_model.get_url(comment,request, permalink=True, anchor='comment-{}'.format(comment.comment_id))}" title="${_('Copy permalink')}"></span>
224 224 </div>
225 225
226 226 ## show delete comment if it's not a PR (regular comments) or it's PR that is not closed
227 227 ## only super-admin, repo admin OR comment owner can delete, also hide delete if currently viewed comment is outdated
228 228 %if not outdated_at_ver and (not comment.pull_request or (comment.pull_request and not comment.pull_request.is_closed())):
229 229 ## permissions to delete
230 230 %if comment.immutable is False and (c.is_super_admin or h.HasRepoPermissionAny('repository.admin')(c.repo_name) or comment.author.user_id == c.rhodecode_user.user_id):
231 231 <div class="dropdown-divider"></div>
232 232 <div class="dropdown-item">
233 233 <a onclick="return Rhodecode.comments.editComment(this, '${comment.line_no}', '${comment.f_path}');" class="btn btn-link btn-sm edit-comment">${_('Edit')}</a>
234 234 </div>
235 235 <div class="dropdown-item">
236 236 <a onclick="return Rhodecode.comments.deleteComment(this);" class="btn btn-link btn-sm btn-danger delete-comment">${_('Delete')}</a>
237 237 </div>
238 238 ## Only available in EE edition
239 239 % if comment.draft and c.rhodecode_edition_id == 'EE':
240 240 <div class="dropdown-item">
241 241 <a onclick="return Rhodecode.comments.finalizeDrafts([${comment.comment_id}]);" class="btn btn-link btn-sm finalize-draft-comment">${_('Submit draft')}</a>
242 242 </div>
243 243 % endif
244 244 %else:
245 245 <div class="dropdown-divider"></div>
246 246 <div class="dropdown-item">
247 247 <a class="tooltip edit-comment link-disabled" disabled="disabled" title="${_('Action unavailable')}">${_('Edit')}</a>
248 248 </div>
249 249 <div class="dropdown-item">
250 250 <a class="tooltip edit-comment link-disabled" disabled="disabled" title="${_('Action unavailable')}">${_('Delete')}</a>
251 251 </div>
252 252 %endif
253 253 %else:
254 254 <div class="dropdown-divider"></div>
255 255 <div class="dropdown-item">
256 256 <a class="tooltip edit-comment link-disabled" disabled="disabled" title="${_('Action unavailable')}">${_('Edit')}</a>
257 257 </div>
258 258 <div class="dropdown-item">
259 259 <a class="tooltip edit-comment link-disabled" disabled="disabled" title="${_('Action unavailable')}">${_('Delete')}</a>
260 260 </div>
261 261 %endif
262 262 </details-menu>
263 263 </details>
264 264
265 265 <code class="action-divider">|</code>
266 266 % if outdated_at_ver:
267 267 <a onclick="return Rhodecode.comments.prevOutdatedComment(this);" class="tooltip prev-comment" title="${_('Jump to the previous outdated comment')}"> <i class="icon-angle-left"></i> </a>
268 268 <a onclick="return Rhodecode.comments.nextOutdatedComment(this);" class="tooltip next-comment" title="${_('Jump to the next outdated comment')}"> <i class="icon-angle-right"></i></a>
269 269 % else:
270 270 <a onclick="return Rhodecode.comments.prevComment(this);" class="tooltip prev-comment" title="${_('Jump to the previous comment')}"> <i class="icon-angle-left"></i></a>
271 271 <a onclick="return Rhodecode.comments.nextComment(this);" class="tooltip next-comment" title="${_('Jump to the next comment')}"> <i class="icon-angle-right"></i></a>
272 272 % endif
273 273
274 274 </div>
275 275 </div>
276 276 <div class="text">
277 277 ${h.render(comment.text, renderer=comment.renderer, mentions=True, repo_name=getattr(c, 'repo_name', None), active_pattern_entries=active_pattern_entries)}
278 278 </div>
279 279
280 280 </div>
281 281 % endif
282 282 </%def>
283 283
284 284 ## generate main comments
285 285 <%def name="generate_comments(comments, include_pull_request=False, is_pull_request=False)">
286 286 <%
287 287 active_pattern_entries = h.get_active_pattern_entries(getattr(c, 'repo_name', None))
288 288 %>
289 289
290 290 <div class="general-comments" id="comments">
291 291 %for comment in comments:
292 292 <div id="comment-tr-${comment.comment_id}">
293 293 ## only render comments that are not from pull request, or from
294 294 ## pull request and a status change
295 295 %if not comment.pull_request or (comment.pull_request and comment.status_change) or include_pull_request:
296 296 ${comment_block(comment, active_pattern_entries=active_pattern_entries)}
297 297 %endif
298 298 </div>
299 299 %endfor
300 300 ## to anchor ajax comments
301 301 <div id="injected_page_comments"></div>
302 302 </div>
303 303 </%def>
304 304
305 305
306 306 <%def name="comments(post_url, cur_status, is_pull_request=False, is_compare=False, change_status=True, form_extras=None)">
307 307
308 308 <div class="comments">
309 309 <%
310 310 if is_pull_request:
311 311 placeholder = _('Leave a comment on this Pull Request.')
312 312 elif is_compare:
313 313 placeholder = _('Leave a comment on {} commits in this range.').format(len(form_extras))
314 314 else:
315 315 placeholder = _('Leave a comment on this Commit.')
316 316 %>
317 317
318 318 % if c.rhodecode_user.username != h.DEFAULT_USER:
319 319 <div class="js-template" id="cb-comment-general-form-template">
320 320 ## template generated for injection
321 321 ${comment_form(form_type='general', review_statuses=c.commit_statuses, form_extras=form_extras)}
322 322 </div>
323 323
324 324 <div id="cb-comment-general-form-placeholder" class="comment-form ac">
325 325 ## inject form here
326 326 </div>
327 327 <script type="text/javascript">
328 328 var resolvesCommentId = null;
329 329 var generalCommentForm = Rhodecode.comments.createGeneralComment(
330 330 'general', "${placeholder}", resolvesCommentId);
331 331
332 332 // set custom success callback on rangeCommit
333 333 % if is_compare:
334 334 generalCommentForm.setHandleFormSubmit(function(o) {
335 335 var self = generalCommentForm;
336 336
337 337 var text = self.cm.getValue();
338 338 var status = self.getCommentStatus();
339 339 var commentType = self.getCommentType();
340 340 var isDraft = self.getDraftState();
341 341
342 342 if (text === "" && !status) {
343 343 return;
344 344 }
345 345
346 346 // we can pick which commits we want to make the comment by
347 347 // selecting them via click on preview pane, this will alter the hidden inputs
348 348 var cherryPicked = $('#changeset_compare_view_content .compare_select.hl').length > 0;
349 349
350 350 var commitIds = [];
351 351 $('#changeset_compare_view_content .compare_select').each(function(el) {
352 352 var commitId = this.id.replace('row-', '');
353 353 if ($(this).hasClass('hl') || !cherryPicked) {
354 354 $("input[data-commit-id='{0}']".format(commitId)).val(commitId);
355 355 commitIds.push(commitId);
356 356 } else {
357 357 $("input[data-commit-id='{0}']".format(commitId)).val('')
358 358 }
359 359 });
360 360
361 361 self.setActionButtonsDisabled(true);
362 362 self.cm.setOption("readOnly", true);
363 363 var postData = {
364 364 'text': text,
365 365 'changeset_status': status,
366 366 'comment_type': commentType,
367 367 'draft': isDraft,
368 368 'commit_ids': commitIds,
369 369 'csrf_token': CSRF_TOKEN
370 370 };
371 371
372 372 var submitSuccessCallback = function(o) {
373 373 location.reload(true);
374 374 };
375 375 var submitFailCallback = function(){
376 376 self.resetCommentFormState(text)
377 377 };
378 378 self.submitAjaxPOST(
379 379 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
380 380 });
381 381 % endif
382 382
383 383 </script>
384 384 % else:
385 385 ## form state when not logged in
386 386 <div class="comment-form ac">
387 387
388 388 <div class="comment-area">
389 389 <div class="comment-area-header">
390 390 <ul class="nav-links clearfix">
391 391 <li class="active">
392 392 <a class="disabled" href="#edit-btn" disabled="disabled" onclick="return false">${_('Write')}</a>
393 393 </li>
394 394 <li class="">
395 395 <a class="disabled" href="#preview-btn" disabled="disabled" onclick="return false">${_('Preview')}</a>
396 396 </li>
397 397 </ul>
398 398 </div>
399 399
400 400 <div class="comment-area-write" style="display: block;">
401 401 <div id="edit-container">
402 402 <div style="padding: 20px 0px 0px 0;">
403 403 ${_('You need to be logged in to leave comments.')}
404 404 <a href="${h.route_path('login', _query={'came_from': h.current_route_path(request)})}">${_('Login now')}</a>
405 405 </div>
406 406 </div>
407 407 <div id="preview-container" class="clearfix" style="display: none;">
408 408 <div id="preview-box" class="preview-box"></div>
409 409 </div>
410 410 </div>
411 411
412 412 <div class="comment-area-footer">
413 413 <div class="toolbar">
414 414 <div class="toolbar-text">
415 415 </div>
416 416 </div>
417 417 </div>
418 418 </div>
419 419
420 420 <div class="comment-footer">
421 421 </div>
422 422
423 423 </div>
424 424 % endif
425 425
426 426 <script type="text/javascript">
427 427 bindToggleButtons();
428 428 </script>
429 429 </div>
430 430 </%def>
431 431
432 432
433 433 <%def name="comment_form(form_type, form_id='', lineno_id='{1}', review_statuses=None, form_extras=None)">
434 434
435 435 ## comment injected based on assumption that user is logged in
436 436 <form ${('id="{}"'.format(form_id) if form_id else '') |n} action="#" method="GET">
437 437
438 438 <div class="comment-area">
439 439 <div class="comment-area-header">
440 440 <div class="pull-left">
441 441 <ul class="nav-links clearfix">
442 442 <li class="active">
443 443 <a href="#edit-btn" tabindex="-1" id="edit-btn_${lineno_id}">${_('Write')}</a>
444 444 </li>
445 445 <li class="">
446 446 <a href="#preview-btn" tabindex="-1" id="preview-btn_${lineno_id}">${_('Preview')}</a>
447 447 </li>
448 448 </ul>
449 449 </div>
450 450 <div class="pull-right">
451 451 <span class="comment-area-text">${_('Mark as')}:</span>
452 452 <select class="comment-type" id="comment_type_${lineno_id}" name="comment_type">
453 453 % for val in c.visual.comment_types:
454 454 <option value="${val}">${val.upper()}</option>
455 455 % endfor
456 456 </select>
457 457 </div>
458 458 </div>
459 459
460 460 <div class="comment-area-write" style="display: block;">
461 461 <div id="edit-container_${lineno_id}" style="margin-top: -1px">
462 462 <textarea id="text_${lineno_id}" name="text" class="comment-block-ta ac-input"></textarea>
463 463 </div>
464 464 <div id="preview-container_${lineno_id}" class="clearfix" style="display: none;">
465 465 <div id="preview-box_${lineno_id}" class="preview-box"></div>
466 466 </div>
467 467 </div>
468 468
469 469 <div class="comment-area-footer comment-attachment-uploader">
470 470 <div class="toolbar">
471 471
472 472 <div class="comment-attachment-text">
473 473 <div class="dropzone-text">
474 474 ${_("Drag'n Drop files here or")} <span class="link pick-attachment">${_('Choose your files')}</span>.<br>
475 475 </div>
476 476 <div class="dropzone-upload" style="display:none">
477 477 <i class="icon-spin animate-spin"></i> ${_('uploading...')}
478 478 </div>
479 479 </div>
480 480
481 481 ## comments dropzone template, empty on purpose
482 482 <div style="display: none" class="comment-attachment-uploader-template">
483 483 <div class="dz-file-preview" style="margin: 0">
484 484 <div class="dz-error-message"></div>
485 485 </div>
486 486 </div>
487 487
488 488 </div>
489 489 </div>
490 490 </div>
491 491
492 492 <div class="comment-footer">
493 493
494 494 ## inject extra inputs into the form
495 495 % if form_extras and isinstance(form_extras, (list, tuple)):
496 496 <div id="comment_form_extras">
497 497 % for form_ex_el in form_extras:
498 498 ${form_ex_el|n}
499 499 % endfor
500 500 </div>
501 501 % endif
502 502
503 503 <div class="action-buttons">
504 504 % if form_type != 'inline':
505 505 <div class="action-buttons-extra"></div>
506 506 % endif
507 507
508 508 <input class="btn btn-success comment-button-input submit-comment-action" id="save_${lineno_id}" name="save" type="submit" value="${_('Add comment')}" data-is-draft=false onclick="$(this).addClass('submitter')">
509 509
510 510 % if form_type == 'inline':
511 511 % if c.rhodecode_edition_id == 'EE':
512 512 ## Disable the button for CE, the "real" validation is in the backend code anyway
513 513 <input class="btn btn-draft comment-button-input submit-draft-action" id="save_draft_${lineno_id}" name="save_draft" type="submit" value="${_('Add draft')}" data-is-draft=true onclick="$(this).addClass('submitter')">
514 514 % else:
515 515 <input class="btn btn-draft comment-button-input submit-draft-action disabled" disabled="disabled" type="submit" value="${_('Add draft')}" onclick="return false;" title="Draft comments only available in EE edition of RhodeCode">
516 516 % endif
517 517 % endif
518 518
519 519 % if review_statuses:
520 520 <div class="comment-status-box">
521 521 <select id="change_status_${lineno_id}" name="changeset_status">
522 522 <option></option> ## Placeholder
523 523 % for status, lbl in review_statuses:
524 524 <option value="${status}" data-status="${status}">${lbl}</option>
525 525 %if is_pull_request and change_status and status in ('approved', 'rejected'):
526 526 <option value="${status}_closed" data-status="${status}">${lbl} & ${_('Closed')}</option>
527 527 %endif
528 528 % endfor
529 529 </select>
530 530 </div>
531 531 % endif
532 532
533 533 ## inline for has a file, and line-number together with cancel hide button.
534 534 % if form_type == 'inline':
535 535 <input type="hidden" name="f_path" value="{0}">
536 536 <input type="hidden" name="line" value="${lineno_id}">
537 537 <span style="opacity: 0.7" class="cursor-pointer cb-comment-cancel" onclick="return Rhodecode.comments.cancelComment(this);">
538 538 ${_('dismiss')}
539 539 </span>
540 540 % endif
541 541 </div>
542 542
543 543 <div class="toolbar-text">
544 544 <% renderer_url = '<a href="%s">%s</a>' % (h.route_url('%s_help' % c.visual.default_renderer), c.visual.default_renderer.upper()) %>
545 545 <span>${_('{} is supported.').format(renderer_url)|n}
546 546
547 547 <i class="icon-info-circled tooltip-hovercard"
548 548 data-hovercard-alt="ALT"
549 549 data-hovercard-url="javascript:commentHelp('${c.visual.default_renderer.upper()}')"
550 data-comment-json-b64='${h.b64(h.json.dumps({}))}'></i>
550 data-comment-json-b64='${h.b64(h.str_json({}))}'></i>
551 551 </span>
552 552 </div>
553 553 </div>
554 554
555 555 </form>
556 556
557 557 </%def> No newline at end of file
@@ -1,1404 +1,1404 b''
1 1 <%namespace name="base" file="/base/base.mako"/>
2 2 <%namespace name="commentblock" file="/changeset/changeset_file_comment.mako"/>
3 3
4 4 <%def name="diff_line_anchor(commit, filename, line, type)"><%
5 5 return '%s_%s_%i' % (h.md5_safe(commit+filename), type, line)
6 6 %></%def>
7 7
8 8 <%def name="action_class(action)">
9 9 <%
10 10 return {
11 11 '-': 'cb-deletion',
12 12 '+': 'cb-addition',
13 13 ' ': 'cb-context',
14 14 }.get(action, 'cb-empty')
15 15 %>
16 16 </%def>
17 17
18 18 <%def name="op_class(op_id)">
19 19 <%
20 20 return {
21 21 DEL_FILENODE: 'deletion', # file deleted
22 22 BIN_FILENODE: 'warning' # binary diff hidden
23 23 }.get(op_id, 'addition')
24 24 %>
25 25 </%def>
26 26
27 27
28 28
29 29 <%def name="render_diffset(diffset, commit=None,
30 30
31 31 # collapse all file diff entries when there are more than this amount of files in the diff
32 32 collapse_when_files_over=20,
33 33
34 34 # collapse lines in the diff when more than this amount of lines changed in the file diff
35 35 lines_changed_limit=500,
36 36
37 37 # add a ruler at to the output
38 38 ruler_at_chars=0,
39 39
40 40 # show inline comments
41 41 use_comments=False,
42 42
43 43 # disable new comments
44 44 disable_new_comments=False,
45 45
46 46 # special file-comments that were deleted in previous versions
47 47 # it's used for showing outdated comments for deleted files in a PR
48 48 deleted_files_comments=None,
49 49
50 50 # for cache purpose
51 51 inline_comments=None,
52 52
53 53 # additional menu for PRs
54 54 pull_request_menu=None,
55 55
56 56 # show/hide todo next to comments
57 57 show_todos=True,
58 58
59 59 )">
60 60
61 61 <%
62 62 diffset_container_id = h.md5(diffset.target_ref)
63 63 collapse_all = len(diffset.files) > collapse_when_files_over
64 64 active_pattern_entries = h.get_active_pattern_entries(getattr(c, 'repo_name', None))
65 65 from rhodecode.lib.diffs import NEW_FILENODE, DEL_FILENODE, \
66 66 MOD_FILENODE, RENAMED_FILENODE, CHMOD_FILENODE, BIN_FILENODE, COPIED_FILENODE
67 67 %>
68 68
69 69 %if use_comments:
70 70
71 71 ## Template for injecting comments
72 72 <div id="cb-comments-inline-container-template" class="js-template">
73 73 ${inline_comments_container([])}
74 74 </div>
75 75
76 76 <div class="js-template" id="cb-comment-inline-form-template">
77 77 <div class="comment-inline-form ac">
78 78 %if not c.rhodecode_user.is_default:
79 79 ## render template for inline comments
80 80 ${commentblock.comment_form(form_type='inline')}
81 81 %endif
82 82 </div>
83 83 </div>
84 84
85 85 %endif
86 86
87 87 %if c.user_session_attrs["diffmode"] == 'sideside':
88 88 <style>
89 89 .wrapper {
90 90 max-width: 1600px !important;
91 91 }
92 92 </style>
93 93 %endif
94 94
95 95 %if ruler_at_chars:
96 96 <style>
97 97 .diff table.cb .cb-content:after {
98 98 content: "";
99 99 border-left: 1px solid blue;
100 100 position: absolute;
101 101 top: 0;
102 102 height: 18px;
103 103 opacity: .2;
104 104 z-index: 10;
105 105 //## +5 to account for diff action (+/-)
106 106 left: ${ruler_at_chars + 5}ch;
107 107 </style>
108 108 %endif
109 109
110 110 <div class="diffset ${disable_new_comments and 'diffset-comments-disabled'}">
111 111
112 112 <div style="height: 20px; line-height: 20px">
113 113 ## expand/collapse action
114 114 <div class="pull-left">
115 115 <a class="${'collapsed' if collapse_all else ''}" href="#expand-files" onclick="toggleExpand(this, '${diffset_container_id}'); return false">
116 116 % if collapse_all:
117 117 <i class="icon-plus-squared-alt icon-no-margin"></i>${_('Expand all files')}
118 118 % else:
119 119 <i class="icon-minus-squared-alt icon-no-margin"></i>${_('Collapse all files')}
120 120 % endif
121 121 </a>
122 122
123 123 </div>
124 124
125 125 ## todos
126 126 % if show_todos and getattr(c, 'at_version', None):
127 127 <div class="pull-right">
128 128 <i class="icon-flag-filled" style="color: #949494">TODOs:</i>
129 129 ${_('not available in this view')}
130 130 </div>
131 131 % elif show_todos:
132 132 <div class="pull-right">
133 133 <div class="comments-number" style="padding-left: 10px">
134 134 % if hasattr(c, 'unresolved_comments') and hasattr(c, 'resolved_comments'):
135 135 <i class="icon-flag-filled" style="color: #949494">TODOs:</i>
136 136 % if c.unresolved_comments:
137 137 <a href="#show-todos" onclick="$('#todo-box').toggle(); return false">
138 138 ${_('{} unresolved').format(len(c.unresolved_comments))}
139 139 </a>
140 140 % else:
141 141 ${_('0 unresolved')}
142 142 % endif
143 143
144 144 ${_('{} Resolved').format(len(c.resolved_comments))}
145 145 % endif
146 146 </div>
147 147 </div>
148 148 % endif
149 149
150 150 ## ## comments
151 151 ## <div class="pull-right">
152 152 ## <div class="comments-number" style="padding-left: 10px">
153 153 ## % if hasattr(c, 'comments') and hasattr(c, 'inline_cnt'):
154 154 ## <i class="icon-comment" style="color: #949494">COMMENTS:</i>
155 155 ## % if c.comments:
156 156 ## <a href="#comments">${_ungettext("{} General", "{} General", len(c.comments)).format(len(c.comments))}</a>,
157 157 ## % else:
158 158 ## ${_('0 General')}
159 159 ## % endif
160 160 ##
161 161 ## % if c.inline_cnt:
162 162 ## <a href="#" onclick="return Rhodecode.comments.nextComment();"
163 163 ## id="inline-comments-counter">${_ungettext("{} Inline", "{} Inline", c.inline_cnt).format(c.inline_cnt)}
164 164 ## </a>
165 165 ## % else:
166 166 ## ${_('0 Inline')}
167 167 ## % endif
168 168 ## % endif
169 169 ##
170 170 ## % if pull_request_menu:
171 171 ## <%
172 172 ## outdated_comm_count_ver = pull_request_menu['outdated_comm_count_ver']
173 173 ## %>
174 174 ##
175 175 ## % if outdated_comm_count_ver:
176 176 ## <a href="#" onclick="showOutdated(); Rhodecode.comments.nextOutdatedComment(); return false;">
177 177 ## (${_("{} Outdated").format(outdated_comm_count_ver)})
178 178 ## </a>
179 179 ## <a href="#" class="showOutdatedComments" onclick="showOutdated(this); return false;"> | ${_('show outdated')}</a>
180 180 ## <a href="#" class="hideOutdatedComments" style="display: none" onclick="hideOutdated(this); return false;"> | ${_('hide outdated')}</a>
181 181 ## % else:
182 182 ## (${_("{} Outdated").format(outdated_comm_count_ver)})
183 183 ## % endif
184 184 ##
185 185 ## % endif
186 186 ##
187 187 ## </div>
188 188 ## </div>
189 189
190 190 </div>
191 191
192 192 % if diffset.limited_diff:
193 193 <div class="diffset-heading ${(diffset.limited_diff and 'diffset-heading-warning' or '')}">
194 194 <h2 class="clearinner">
195 195 ${_('The requested changes are too big and content was truncated.')}
196 196 <a href="${h.current_route_path(request, fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
197 197 </h2>
198 198 </div>
199 199 % endif
200 200
201 201 <div id="todo-box">
202 202 % if hasattr(c, 'unresolved_comments') and c.unresolved_comments:
203 203 % for co in c.unresolved_comments:
204 204 <a class="permalink" href="#comment-${co.comment_id}"
205 205 onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'))">
206 206 <i class="icon-flag-filled-red"></i>
207 207 ${co.comment_id}</a>${('' if loop.last else ',')}
208 208 % endfor
209 209 % endif
210 210 </div>
211 211 %if diffset.has_hidden_changes:
212 212 <p class="empty_data">${_('Some changes may be hidden')}</p>
213 213 %elif not diffset.files:
214 214 <p class="empty_data">${_('No files')}</p>
215 215 %endif
216 216
217 217 <div class="filediffs">
218 218
219 219 ## initial value could be marked as False later on
220 220 <% over_lines_changed_limit = False %>
221 221 %for i, filediff in enumerate(diffset.files):
222 222
223 223 %if filediff.source_file_path and filediff.target_file_path:
224 224 %if filediff.source_file_path != filediff.target_file_path:
225 225 ## file was renamed, or copied
226 226 %if RENAMED_FILENODE in filediff.patch['stats']['ops']:
227 227 <%
228 228 final_file_name = h.literal(u'{} <i class="icon-angle-left"></i> <del>{}</del>'.format(filediff.target_file_path, filediff.source_file_path))
229 229 final_path = filediff.target_file_path
230 230 %>
231 231 %elif COPIED_FILENODE in filediff.patch['stats']['ops']:
232 232 <%
233 233 final_file_name = h.literal(u'{} <i class="icon-angle-left"></i> {}'.format(filediff.target_file_path, filediff.source_file_path))
234 234 final_path = filediff.target_file_path
235 235 %>
236 236 %endif
237 237 %else:
238 238 ## file was modified
239 239 <%
240 240 final_file_name = filediff.source_file_path
241 241 final_path = final_file_name
242 242 %>
243 243 %endif
244 244 %else:
245 245 %if filediff.source_file_path:
246 246 ## file was deleted
247 247 <%
248 248 final_file_name = filediff.source_file_path
249 249 final_path = final_file_name
250 250 %>
251 251 %else:
252 252 ## file was added
253 253 <%
254 254 final_file_name = filediff.target_file_path
255 255 final_path = final_file_name
256 256 %>
257 257 %endif
258 258 %endif
259 259
260 260 <%
261 261 lines_changed = filediff.patch['stats']['added'] + filediff.patch['stats']['deleted']
262 262 over_lines_changed_limit = lines_changed > lines_changed_limit
263 263 %>
264 264 ## anchor with support of sticky header
265 265 <div class="anchor" id="a_${h.FID(filediff.raw_id, filediff.patch['filename'])}"></div>
266 266
267 267 <input ${(collapse_all and 'checked' or '')} class="filediff-collapse-state collapse-${diffset_container_id}" id="filediff-collapse-${id(filediff)}" type="checkbox" onchange="updateSticky();">
268 268 <div
269 269 class="filediff"
270 270 data-f-path="${filediff.patch['filename']}"
271 271 data-anchor-id="${h.FID(filediff.raw_id, filediff.patch['filename'])}"
272 272 >
273 273 <label for="filediff-collapse-${id(filediff)}" class="filediff-heading">
274 274 <%
275 275 file_comments = (get_inline_comments(inline_comments, filediff.patch['filename']) or {}).values()
276 276 total_file_comments = [_c for _c in h.itertools.chain.from_iterable(file_comments) if not (_c.outdated or _c.draft)]
277 277 %>
278 278 <div class="filediff-collapse-indicator icon-"></div>
279 279
280 280 ## Comments/Options PILL
281 281 <span class="pill-group pull-right">
282 282 <span class="pill" op="comments">
283 283 <i class="icon-comment"></i> ${len(total_file_comments)}
284 284 </span>
285 285
286 286 <details class="details-reset details-inline-block">
287 287 <summary class="noselect">
288 288 <i class="pill icon-options cursor-pointer" op="options"></i>
289 289 </summary>
290 290 <details-menu class="details-dropdown">
291 291
292 292 <div class="dropdown-item">
293 293 <span>${final_path}</span>
294 294 <span class="pull-right icon-clipboard clipboard-action" data-clipboard-text="${final_path}" title="Copy file path"></span>
295 295 </div>
296 296
297 297 <div class="dropdown-divider"></div>
298 298
299 299 <div class="dropdown-item">
300 300 <% permalink = request.current_route_url(_anchor='a_{}'.format(h.FID(filediff.raw_id, filediff.patch['filename']))) %>
301 301 <a href="${permalink}">ΒΆ permalink</a>
302 302 <span class="pull-right icon-clipboard clipboard-action" data-clipboard-text="${permalink}" title="Copy permalink"></span>
303 303 </div>
304 304
305 305
306 306 </details-menu>
307 307 </details>
308 308
309 309 </span>
310 310
311 311 ${diff_ops(final_file_name, filediff)}
312 312
313 313 </label>
314 314
315 315 ${diff_menu(filediff, use_comments=use_comments)}
316 316 <table id="file-${h.safeid(h.safe_unicode(filediff.patch['filename']))}" data-f-path="${filediff.patch['filename']}" data-anchor-id="${h.FID(filediff.raw_id, filediff.patch['filename'])}" class="code-visible-block cb cb-diff-${c.user_session_attrs["diffmode"]} code-highlight ${(over_lines_changed_limit and 'cb-collapsed' or '')}">
317 317
318 318 ## new/deleted/empty content case
319 319 % if not filediff.hunks:
320 320 ## Comment container, on "fakes" hunk that contains all data to render comments
321 321 ${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], filediff.hunk_ops, use_comments=use_comments, inline_comments=inline_comments, active_pattern_entries=active_pattern_entries)}
322 322 % endif
323 323
324 324 %if filediff.limited_diff:
325 325 <tr class="cb-warning cb-collapser">
326 326 <td class="cb-text" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=6')}>
327 327 ${_('The requested commit or file is too big and content was truncated.')} <a href="${h.current_route_path(request, fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
328 328 </td>
329 329 </tr>
330 330 %else:
331 331 %if over_lines_changed_limit:
332 332 <tr class="cb-warning cb-collapser">
333 333 <td class="cb-text" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=6')}>
334 334 ${_('This diff has been collapsed as it changes many lines, (%i lines changed)' % lines_changed)}
335 335 <a href="#" class="cb-expand"
336 336 onclick="$(this).closest('table').removeClass('cb-collapsed'); updateSticky(); return false;">${_('Show them')}
337 337 </a>
338 338 <a href="#" class="cb-collapse"
339 339 onclick="$(this).closest('table').addClass('cb-collapsed'); updateSticky(); return false;">${_('Hide them')}
340 340 </a>
341 341 </td>
342 342 </tr>
343 343 %endif
344 344 %endif
345 345
346 346 % for hunk in filediff.hunks:
347 347 <tr class="cb-hunk">
348 348 <td ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=3' or '')}>
349 349 ## TODO: dan: add ajax loading of more context here
350 350 ## <a href="#">
351 351 <i class="icon-more"></i>
352 352 ## </a>
353 353 </td>
354 354 <td ${(c.user_session_attrs["diffmode"] == 'sideside' and 'colspan=5' or '')}>
355 355 @@
356 356 -${hunk.source_start},${hunk.source_length}
357 357 +${hunk.target_start},${hunk.target_length}
358 358 ${hunk.section_header}
359 359 </td>
360 360 </tr>
361 361
362 362 ${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], hunk, use_comments=use_comments, inline_comments=inline_comments, active_pattern_entries=active_pattern_entries)}
363 363 % endfor
364 364
365 365 <% unmatched_comments = (inline_comments or {}).get(filediff.patch['filename'], {}) %>
366 366
367 367 ## outdated comments that do not fit into currently displayed lines
368 368 % for lineno, comments in unmatched_comments.items():
369 369
370 370 %if c.user_session_attrs["diffmode"] == 'unified':
371 371 % if loop.index == 0:
372 372 <tr class="cb-hunk">
373 373 <td colspan="3"></td>
374 374 <td>
375 375 <div>
376 376 ${_('Unmatched/outdated inline comments below')}
377 377 </div>
378 378 </td>
379 379 </tr>
380 380 % endif
381 381 <tr class="cb-line">
382 382 <td class="cb-data cb-context"></td>
383 383 <td class="cb-lineno cb-context"></td>
384 384 <td class="cb-lineno cb-context"></td>
385 385 <td class="cb-content cb-context">
386 386 ${inline_comments_container(comments, active_pattern_entries=active_pattern_entries)}
387 387 </td>
388 388 </tr>
389 389 %elif c.user_session_attrs["diffmode"] == 'sideside':
390 390 % if loop.index == 0:
391 391 <tr class="cb-comment-info">
392 392 <td colspan="2"></td>
393 393 <td class="cb-line">
394 394 <div>
395 395 ${_('Unmatched/outdated inline comments below')}
396 396 </div>
397 397 </td>
398 398 <td colspan="2"></td>
399 399 <td class="cb-line">
400 400 <div>
401 401 ${_('Unmatched/outdated comments below')}
402 402 </div>
403 403 </td>
404 404 </tr>
405 405 % endif
406 406 <tr class="cb-line">
407 407 <td class="cb-data cb-context"></td>
408 408 <td class="cb-lineno cb-context"></td>
409 409 <td class="cb-content cb-context">
410 410 % if lineno.startswith('o'):
411 411 ${inline_comments_container(comments, active_pattern_entries=active_pattern_entries)}
412 412 % endif
413 413 </td>
414 414
415 415 <td class="cb-data cb-context"></td>
416 416 <td class="cb-lineno cb-context"></td>
417 417 <td class="cb-content cb-context">
418 418 % if lineno.startswith('n'):
419 419 ${inline_comments_container(comments, active_pattern_entries=active_pattern_entries)}
420 420 % endif
421 421 </td>
422 422 </tr>
423 423 %endif
424 424
425 425 % endfor
426 426
427 427 </table>
428 428 </div>
429 429 %endfor
430 430
431 431 ## outdated comments that are made for a file that has been deleted
432 432 % for filename, comments_dict in (deleted_files_comments or {}).items():
433 433
434 434 <%
435 435 display_state = 'display: none'
436 436 open_comments_in_file = [x for x in comments_dict['comments'] if x.outdated is False]
437 437 if open_comments_in_file:
438 438 display_state = ''
439 439 fid = str(id(filename))
440 440 %>
441 441 <div class="filediffs filediff-outdated" style="${display_state}">
442 442 <input ${(collapse_all and 'checked' or '')} class="filediff-collapse-state collapse-${diffset_container_id}" id="filediff-collapse-${id(filename)}" type="checkbox" onchange="updateSticky();">
443 443 <div class="filediff" data-f-path="${filename}" id="a_${h.FID(fid, filename)}">
444 444 <label for="filediff-collapse-${id(filename)}" class="filediff-heading">
445 445 <div class="filediff-collapse-indicator icon-"></div>
446 446
447 447 <span class="pill">
448 448 ## file was deleted
449 449 ${filename}
450 450 </span>
451 451 <span class="pill-group pull-left" >
452 452 ## file op, doesn't need translation
453 453 <span class="pill" op="removed">unresolved comments</span>
454 454 </span>
455 455 <a class="pill filediff-anchor" href="#a_${h.FID(fid, filename)}">ΒΆ</a>
456 456 <span class="pill-group pull-right">
457 457 <span class="pill" op="deleted">
458 458 % if comments_dict['stats'] >0:
459 459 -${comments_dict['stats']}
460 460 % else:
461 461 ${comments_dict['stats']}
462 462 % endif
463 463 </span>
464 464 </span>
465 465 </label>
466 466
467 467 <table class="cb cb-diff-${c.user_session_attrs["diffmode"]} code-highlight ${(over_lines_changed_limit and 'cb-collapsed' or '')}">
468 468 <tr>
469 469 % if c.user_session_attrs["diffmode"] == 'unified':
470 470 <td></td>
471 471 %endif
472 472
473 473 <td></td>
474 474 <td class="cb-text cb-${op_class(BIN_FILENODE)}" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=5')}>
475 475 <strong>${_('This file was removed from diff during updates to this pull-request.')}</strong><br/>
476 476 ${_('There are still outdated/unresolved comments attached to it.')}
477 477 </td>
478 478 </tr>
479 479 %if c.user_session_attrs["diffmode"] == 'unified':
480 480 <tr class="cb-line">
481 481 <td class="cb-data cb-context"></td>
482 482 <td class="cb-lineno cb-context"></td>
483 483 <td class="cb-lineno cb-context"></td>
484 484 <td class="cb-content cb-context">
485 485 ${inline_comments_container(comments_dict['comments'], active_pattern_entries=active_pattern_entries)}
486 486 </td>
487 487 </tr>
488 488 %elif c.user_session_attrs["diffmode"] == 'sideside':
489 489 <tr class="cb-line">
490 490 <td class="cb-data cb-context"></td>
491 491 <td class="cb-lineno cb-context"></td>
492 492 <td class="cb-content cb-context"></td>
493 493
494 494 <td class="cb-data cb-context"></td>
495 495 <td class="cb-lineno cb-context"></td>
496 496 <td class="cb-content cb-context">
497 497 ${inline_comments_container(comments_dict['comments'], active_pattern_entries=active_pattern_entries)}
498 498 </td>
499 499 </tr>
500 500 %endif
501 501 </table>
502 502 </div>
503 503 </div>
504 504 % endfor
505 505
506 506 </div>
507 507 </div>
508 508 </%def>
509 509
510 510 <%def name="diff_ops(file_name, filediff)">
511 511 <%
512 512 from rhodecode.lib.diffs import NEW_FILENODE, DEL_FILENODE, \
513 513 MOD_FILENODE, RENAMED_FILENODE, CHMOD_FILENODE, BIN_FILENODE, COPIED_FILENODE
514 514 %>
515 515 <span class="pill">
516 516 <i class="icon-file-text"></i>
517 517 ${file_name}
518 518 </span>
519 519
520 520 <span class="pill-group pull-right">
521 521
522 522 ## ops pills
523 523 %if filediff.limited_diff:
524 524 <span class="pill tooltip" op="limited" title="The stats for this diff are not complete">limited diff</span>
525 525 %endif
526 526
527 527 %if NEW_FILENODE in filediff.patch['stats']['ops']:
528 528 <span class="pill" op="created">created</span>
529 529 %if filediff['target_mode'].startswith('120'):
530 530 <span class="pill" op="symlink">symlink</span>
531 531 %else:
532 532 <span class="pill" op="mode">${nice_mode(filediff['target_mode'])}</span>
533 533 %endif
534 534 %endif
535 535
536 536 %if RENAMED_FILENODE in filediff.patch['stats']['ops']:
537 537 <span class="pill" op="renamed">renamed</span>
538 538 %endif
539 539
540 540 %if COPIED_FILENODE in filediff.patch['stats']['ops']:
541 541 <span class="pill" op="copied">copied</span>
542 542 %endif
543 543
544 544 %if DEL_FILENODE in filediff.patch['stats']['ops']:
545 545 <span class="pill" op="removed">removed</span>
546 546 %endif
547 547
548 548 %if CHMOD_FILENODE in filediff.patch['stats']['ops']:
549 549 <span class="pill" op="mode">
550 550 ${nice_mode(filediff['source_mode'])} ➑ ${nice_mode(filediff['target_mode'])}
551 551 </span>
552 552 %endif
553 553
554 554 %if BIN_FILENODE in filediff.patch['stats']['ops']:
555 555 <span class="pill" op="binary">binary</span>
556 556 %if MOD_FILENODE in filediff.patch['stats']['ops']:
557 557 <span class="pill" op="modified">modified</span>
558 558 %endif
559 559 %endif
560 560
561 561 <span class="pill" op="added">${('+' if filediff.patch['stats']['added'] else '')}${filediff.patch['stats']['added']}</span>
562 562 <span class="pill" op="deleted">${((h.safe_int(filediff.patch['stats']['deleted']) or 0) * -1)}</span>
563 563
564 564 </span>
565 565
566 566 </%def>
567 567
568 568 <%def name="nice_mode(filemode)">
569 569 ${(filemode.startswith('100') and filemode[3:] or filemode)}
570 570 </%def>
571 571
572 572 <%def name="diff_menu(filediff, use_comments=False)">
573 573 <div class="filediff-menu">
574 574
575 575 %if filediff.diffset.source_ref:
576 576
577 577 ## FILE BEFORE CHANGES
578 578 %if filediff.operation in ['D', 'M']:
579 579 <a
580 580 class="tooltip"
581 581 href="${h.route_path('repo_files',repo_name=filediff.diffset.target_repo_name,commit_id=filediff.diffset.source_ref,f_path=filediff.source_file_path)}"
582 582 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
583 583 >
584 584 ${_('Show file before')}
585 585 </a> |
586 586 %else:
587 587 <span
588 588 class="tooltip"
589 589 title="${h.tooltip(_('File not present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
590 590 >
591 591 ${_('Show file before')}
592 592 </span> |
593 593 %endif
594 594
595 595 ## FILE AFTER CHANGES
596 596 %if filediff.operation in ['A', 'M']:
597 597 <a
598 598 class="tooltip"
599 599 href="${h.route_path('repo_files',repo_name=filediff.diffset.source_repo_name,commit_id=filediff.diffset.target_ref,f_path=filediff.target_file_path)}"
600 600 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
601 601 >
602 602 ${_('Show file after')}
603 603 </a>
604 604 %else:
605 605 <span
606 606 class="tooltip"
607 607 title="${h.tooltip(_('File not present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
608 608 >
609 609 ${_('Show file after')}
610 610 </span>
611 611 %endif
612 612
613 613 % if use_comments:
614 614 |
615 615 <a href="#" onclick="Rhodecode.comments.toggleDiffComments(this);return toggleElement(this)"
616 616 data-toggle-on="${_('Hide comments')}"
617 617 data-toggle-off="${_('Show comments')}">
618 618 <span class="hide-comment-button">${_('Hide comments')}</span>
619 619 </a>
620 620 % endif
621 621
622 622 %endif
623 623
624 624 </div>
625 625 </%def>
626 626
627 627
628 628 <%def name="inline_comments_container(comments, active_pattern_entries=None, line_no='', f_path='')">
629 629
630 630 <div class="inline-comments">
631 631 %for comment in comments:
632 632 ${commentblock.comment_block(comment, inline=True, active_pattern_entries=active_pattern_entries)}
633 633 %endfor
634 634
635 635 <%
636 636 extra_class = ''
637 637 extra_style = ''
638 638
639 639 if comments and comments[-1].outdated_at_version(c.at_version_num):
640 640 extra_class = ' comment-outdated'
641 641 extra_style = 'display: none;'
642 642
643 643 %>
644 644
645 645 <div class="reply-thread-container-wrapper${extra_class}" style="${extra_style}">
646 646 <div class="reply-thread-container${extra_class}">
647 647 <div class="reply-thread-gravatar">
648 648 % if c.rhodecode_user.username != h.DEFAULT_USER:
649 649 ${base.gravatar(c.rhodecode_user.email, 20, tooltip=True, user=c.rhodecode_user)}
650 650 % endif
651 651 </div>
652 652
653 653 <div class="reply-thread-reply-button">
654 654 % if c.rhodecode_user.username != h.DEFAULT_USER:
655 655 ## initial reply button, some JS logic can append here a FORM to leave a first comment.
656 656 <button class="cb-comment-add-button" onclick="return Rhodecode.comments.createComment(this, '${f_path}', '${line_no}', null)">Reply...</button>
657 657 % endif
658 658 </div>
659 659 ##% endif
660 660 <div class="reply-thread-last"></div>
661 661 </div>
662 662 </div>
663 663 </div>
664 664
665 665 </%def>
666 666
667 667 <%!
668 668
669 669 def get_inline_comments(comments, filename):
670 670 if hasattr(filename, 'unicode_path'):
671 671 filename = filename.unicode_path
672 672
673 673 if not isinstance(filename, str):
674 674 return None
675 675
676 676 if comments and filename in comments:
677 677 return comments[filename]
678 678
679 679 return None
680 680
681 681 def get_comments_for(diff_type, comments, filename, line_version, line_number):
682 682 if hasattr(filename, 'unicode_path'):
683 683 filename = filename.unicode_path
684 684
685 685 if not isinstance(filename, str):
686 686 return None
687 687
688 688 file_comments = get_inline_comments(comments, filename)
689 689 if file_comments is None:
690 690 return None
691 691
692 692 line_key = '{}{}'.format(line_version, line_number) ## e.g o37, n12
693 693 if line_key in file_comments:
694 694 data = file_comments.pop(line_key)
695 695 return data
696 696 %>
697 697
698 698 <%def name="render_hunk_lines_sideside(filediff, hunk, use_comments=False, inline_comments=None, active_pattern_entries=None)">
699 699
700 700 <% chunk_count = 1 %>
701 701 %for loop_obj, item in h.looper(hunk.sideside):
702 702 <%
703 703 line = item
704 704 i = loop_obj.index
705 705 prev_line = loop_obj.previous
706 706 old_line_anchor, new_line_anchor = None, None
707 707
708 708 if line.original.lineno:
709 709 old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, line.original.lineno, 'o')
710 710 if line.modified.lineno:
711 711 new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, line.modified.lineno, 'n')
712 712
713 713 line_action = line.modified.action or line.original.action
714 714 prev_line_action = prev_line and (prev_line.modified.action or prev_line.original.action)
715 715 %>
716 716
717 717 <tr class="cb-line">
718 718 <td class="cb-data ${action_class(line.original.action)}"
719 719 data-line-no="${line.original.lineno}"
720 720 >
721 721
722 722 <% line_old_comments, line_old_comments_no_drafts = None, None %>
723 723 %if line.original.get_comment_args:
724 724 <%
725 725 line_old_comments = get_comments_for('side-by-side', inline_comments, *line.original.get_comment_args)
726 726 line_old_comments_no_drafts = [c for c in line_old_comments if not c.draft] if line_old_comments else []
727 727 has_outdated = any([x.outdated for x in line_old_comments_no_drafts])
728 728 %>
729 729 %endif
730 730 %if line_old_comments_no_drafts:
731 731 % if has_outdated:
732 732 <i class="tooltip toggle-comment-action icon-comment-toggle" title="${_('Comments including outdated: {}. Click here to toggle them.').format(len(line_old_comments_no_drafts))}" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
733 733 % else:
734 734 <i class="tooltip toggle-comment-action icon-comment" title="${_('Comments: {}. Click to toggle them.').format(len(line_old_comments_no_drafts))}" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
735 735 % endif
736 736 %endif
737 737 </td>
738 738 <td class="cb-lineno ${action_class(line.original.action)}"
739 739 data-line-no="${line.original.lineno}"
740 740 %if old_line_anchor:
741 741 id="${old_line_anchor}"
742 742 %endif
743 743 >
744 744 %if line.original.lineno:
745 745 <a name="${old_line_anchor}" href="#${old_line_anchor}">${line.original.lineno}</a>
746 746 %endif
747 747 </td>
748 748
749 749 <% line_no = 'o{}'.format(line.original.lineno) %>
750 750 <td class="cb-content ${action_class(line.original.action)}"
751 751 data-line-no="${line_no}"
752 752 >
753 753 %if use_comments and line.original.lineno:
754 754 ${render_add_comment_button(line_no=line_no, f_path=filediff.patch['filename'])}
755 755 %endif
756 756 <span class="cb-code"><span class="cb-action ${action_class(line.original.action)}"></span>${line.original.content or '' | n}</span>
757 757
758 758 %if use_comments and line.original.lineno and line_old_comments:
759 759 ${inline_comments_container(line_old_comments, active_pattern_entries=active_pattern_entries, line_no=line_no, f_path=filediff.patch['filename'])}
760 760 %endif
761 761
762 762 </td>
763 763 <td class="cb-data ${action_class(line.modified.action)}"
764 764 data-line-no="${line.modified.lineno}"
765 765 >
766 766 <div>
767 767
768 768 <% line_new_comments, line_new_comments_no_drafts = None, None %>
769 769 %if line.modified.get_comment_args:
770 770 <%
771 771 line_new_comments = get_comments_for('side-by-side', inline_comments, *line.modified.get_comment_args)
772 772 line_new_comments_no_drafts = [c for c in line_new_comments if not c.draft] if line_new_comments else []
773 773 has_outdated = any([x.outdated for x in line_new_comments_no_drafts])
774 774 %>
775 775 %endif
776 776
777 777 %if line_new_comments_no_drafts:
778 778 % if has_outdated:
779 779 <i class="tooltip toggle-comment-action icon-comment-toggle" title="${_('Comments including outdated: {}. Click here to toggle them.').format(len(line_new_comments_no_drafts))}" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
780 780 % else:
781 781 <i class="tooltip toggle-comment-action icon-comment" title="${_('Comments: {}. Click to toggle them.').format(len(line_new_comments_no_drafts))}" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
782 782 % endif
783 783 %endif
784 784 </div>
785 785 </td>
786 786 <td class="cb-lineno ${action_class(line.modified.action)}"
787 787 data-line-no="${line.modified.lineno}"
788 788 %if new_line_anchor:
789 789 id="${new_line_anchor}"
790 790 %endif
791 791 >
792 792 %if line.modified.lineno:
793 793 <a name="${new_line_anchor}" href="#${new_line_anchor}">${line.modified.lineno}</a>
794 794 %endif
795 795 </td>
796 796
797 797 <% line_no = 'n{}'.format(line.modified.lineno) %>
798 798 <td class="cb-content ${action_class(line.modified.action)}"
799 799 data-line-no="${line_no}"
800 800 >
801 801 %if use_comments and line.modified.lineno:
802 802 ${render_add_comment_button(line_no=line_no, f_path=filediff.patch['filename'])}
803 803 %endif
804 804 <span class="cb-code"><span class="cb-action ${action_class(line.modified.action)}"></span>${line.modified.content or '' | n}</span>
805 805 % if line_action in ['+', '-'] and prev_line_action not in ['+', '-']:
806 806 <div class="nav-chunk" style="visibility: hidden">
807 807 <i class="icon-eye" title="viewing diff hunk-${hunk.index}-${chunk_count}"></i>
808 808 </div>
809 809 <% chunk_count +=1 %>
810 810 % endif
811 811 %if use_comments and line.modified.lineno and line_new_comments:
812 812 ${inline_comments_container(line_new_comments, active_pattern_entries=active_pattern_entries, line_no=line_no, f_path=filediff.patch['filename'])}
813 813 %endif
814 814
815 815 </td>
816 816 </tr>
817 817 %endfor
818 818 </%def>
819 819
820 820
821 821 <%def name="render_hunk_lines_unified(filediff, hunk, use_comments=False, inline_comments=None, active_pattern_entries=None)">
822 822 %for old_line_no, new_line_no, action, content, comments_args in hunk.unified:
823 823
824 824 <%
825 825 old_line_anchor, new_line_anchor = None, None
826 826 if old_line_no:
827 827 old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, old_line_no, 'o')
828 828 if new_line_no:
829 829 new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, new_line_no, 'n')
830 830 %>
831 831 <tr class="cb-line">
832 832 <td class="cb-data ${action_class(action)}">
833 833 <div>
834 834
835 835 <% comments, comments_no_drafts = None, None %>
836 836 %if comments_args:
837 837 <%
838 838 comments = get_comments_for('unified', inline_comments, *comments_args)
839 839 comments_no_drafts = [c for c in line_new_comments if not c.draft] if line_new_comments else []
840 840 has_outdated = any([x.outdated for x in comments_no_drafts])
841 841 %>
842 842 %endif
843 843
844 844 % if comments_no_drafts:
845 845 % if has_outdated:
846 846 <i class="tooltip toggle-comment-action icon-comment-toggle" title="${_('Comments including outdated: {}. Click here to toggle them.').format(len(comments_no_drafts))}" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
847 847 % else:
848 848 <i class="tooltip toggle-comment-action icon-comment" title="${_('Comments: {}. Click to toggle them.').format(len(comments_no_drafts))}" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
849 849 % endif
850 850 % endif
851 851 </div>
852 852 </td>
853 853 <td class="cb-lineno ${action_class(action)}"
854 854 data-line-no="${old_line_no}"
855 855 %if old_line_anchor:
856 856 id="${old_line_anchor}"
857 857 %endif
858 858 >
859 859 %if old_line_anchor:
860 860 <a name="${old_line_anchor}" href="#${old_line_anchor}">${old_line_no}</a>
861 861 %endif
862 862 </td>
863 863 <td class="cb-lineno ${action_class(action)}"
864 864 data-line-no="${new_line_no}"
865 865 %if new_line_anchor:
866 866 id="${new_line_anchor}"
867 867 %endif
868 868 >
869 869 %if new_line_anchor:
870 870 <a name="${new_line_anchor}" href="#${new_line_anchor}">${new_line_no}</a>
871 871 %endif
872 872 </td>
873 873 <% line_no = '{}{}'.format(new_line_no and 'n' or 'o', new_line_no or old_line_no) %>
874 874 <td class="cb-content ${action_class(action)}"
875 875 data-line-no="${line_no}"
876 876 >
877 877 %if use_comments:
878 878 ${render_add_comment_button(line_no=line_no, f_path=filediff.patch['filename'])}
879 879 %endif
880 880 <span class="cb-code"><span class="cb-action ${action_class(action)}"></span> ${content or '' | n}</span>
881 881 %if use_comments and comments:
882 882 ${inline_comments_container(comments, active_pattern_entries=active_pattern_entries, line_no=line_no, f_path=filediff.patch['filename'])}
883 883 %endif
884 884 </td>
885 885 </tr>
886 886 %endfor
887 887 </%def>
888 888
889 889
890 890 <%def name="render_hunk_lines(filediff, diff_mode, hunk, use_comments, inline_comments, active_pattern_entries)">
891 891 % if diff_mode == 'unified':
892 892 ${render_hunk_lines_unified(filediff, hunk, use_comments=use_comments, inline_comments=inline_comments, active_pattern_entries=active_pattern_entries)}
893 893 % elif diff_mode == 'sideside':
894 894 ${render_hunk_lines_sideside(filediff, hunk, use_comments=use_comments, inline_comments=inline_comments, active_pattern_entries=active_pattern_entries)}
895 895 % else:
896 896 <tr class="cb-line">
897 897 <td>unknown diff mode</td>
898 898 </tr>
899 899 % endif
900 900 </%def>file changes
901 901
902 902
903 903 <%def name="render_add_comment_button(line_no='', f_path='')">
904 904 % if not c.rhodecode_user.is_default:
905 905 <button class="btn btn-small btn-primary cb-comment-box-opener" onclick="return Rhodecode.comments.createComment(this, '${f_path}', '${line_no}', null)">
906 906 <span><i class="icon-comment"></i></span>
907 907 </button>
908 908 % endif
909 909 </%def>
910 910
911 911 <%def name="render_diffset_menu(diffset, range_diff_on=None, commit=None, pull_request_menu=None)">
912 912 <% diffset_container_id = h.md5(diffset.target_ref) %>
913 913
914 914 <div id="diff-file-sticky" class="diffset-menu clearinner">
915 915 ## auto adjustable
916 916 <div class="sidebar__inner">
917 917 <div class="sidebar__bar">
918 918 <div class="pull-right">
919 919
920 920 <div class="btn-group" style="margin-right: 5px;">
921 921 <a class="tooltip btn" onclick="scrollDown();return false" title="${_('Scroll to page bottom')}">
922 922 <i class="icon-arrow_down"></i>
923 923 </a>
924 924 <a class="tooltip btn" onclick="scrollUp();return false" title="${_('Scroll to page top')}">
925 925 <i class="icon-arrow_up"></i>
926 926 </a>
927 927 </div>
928 928
929 929 <div class="btn-group">
930 930 <a class="btn tooltip toggle-wide-diff" href="#toggle-wide-diff" onclick="toggleWideDiff(this); return false" title="${h.tooltip(_('Toggle wide diff'))}">
931 931 <i class="icon-wide-mode"></i>
932 932 </a>
933 933 </div>
934 934 <div class="btn-group">
935 935
936 936 <a
937 937 class="btn ${(c.user_session_attrs["diffmode"] == 'sideside' and 'btn-active')} tooltip"
938 938 title="${h.tooltip(_('View diff as side by side'))}"
939 939 href="${h.current_route_path(request, diffmode='sideside')}">
940 940 <span>${_('Side by Side')}</span>
941 941 </a>
942 942
943 943 <a
944 944 class="btn ${(c.user_session_attrs["diffmode"] == 'unified' and 'btn-active')} tooltip"
945 945 title="${h.tooltip(_('View diff as unified'))}" href="${h.current_route_path(request, diffmode='unified')}">
946 946 <span>${_('Unified')}</span>
947 947 </a>
948 948
949 949 % if range_diff_on is True:
950 950 <a
951 951 title="${_('Turn off: Show the diff as commit range')}"
952 952 class="btn btn-primary"
953 953 href="${h.current_route_path(request, **{"range-diff":"0"})}">
954 954 <span>${_('Range Diff')}</span>
955 955 </a>
956 956 % elif range_diff_on is False:
957 957 <a
958 958 title="${_('Show the diff as commit range')}"
959 959 class="btn"
960 960 href="${h.current_route_path(request, **{"range-diff":"1"})}">
961 961 <span>${_('Range Diff')}</span>
962 962 </a>
963 963 % endif
964 964 </div>
965 965 <div class="btn-group">
966 966
967 967 <details class="details-reset details-inline-block">
968 968 <summary class="noselect btn">
969 969 <i class="icon-options cursor-pointer" op="options"></i>
970 970 </summary>
971 971
972 972 <div>
973 973 <details-menu class="details-dropdown" style="top: 35px;">
974 974
975 975 <div class="dropdown-item">
976 976 <div style="padding: 2px 0px">
977 977 % if request.GET.get('ignorews', '') == '1':
978 978 <a href="${h.current_route_path(request, ignorews=0)}">${_('Show whitespace changes')}</a>
979 979 % else:
980 980 <a href="${h.current_route_path(request, ignorews=1)}">${_('Hide whitespace changes')}</a>
981 981 % endif
982 982 </div>
983 983 </div>
984 984
985 985 <div class="dropdown-item">
986 986 <div style="padding: 2px 0px">
987 987 % if request.GET.get('fullcontext', '') == '1':
988 988 <a href="${h.current_route_path(request, fullcontext=0)}">${_('Hide full context diff')}</a>
989 989 % else:
990 990 <a href="${h.current_route_path(request, fullcontext=1)}">${_('Show full context diff')}</a>
991 991 % endif
992 992 </div>
993 993 </div>
994 994
995 995 </details-menu>
996 996 </div>
997 997 </details>
998 998
999 999 </div>
1000 1000 </div>
1001 1001 <div class="pull-left">
1002 1002 <div class="btn-group">
1003 1003 <div class="pull-left">
1004 1004 ${h.hidden('file_filter_{}'.format(diffset_container_id))}
1005 1005 </div>
1006 1006
1007 1007 </div>
1008 1008 </div>
1009 1009 </div>
1010 1010 <div class="fpath-placeholder pull-left">
1011 1011 <i class="icon-file-text"></i>
1012 1012 <strong class="fpath-placeholder-text">
1013 1013 Context file:
1014 1014 </strong>
1015 1015 </div>
1016 1016 <div class="pull-right noselect">
1017 1017 %if commit:
1018 1018 <span>
1019 1019 <code>${h.show_id(commit)}</code>
1020 1020 </span>
1021 1021 %elif pull_request_menu and pull_request_menu.get('pull_request'):
1022 1022 <span>
1023 1023 <code>!${pull_request_menu['pull_request'].pull_request_id}</code>
1024 1024 </span>
1025 1025 %endif
1026 1026 % if commit or pull_request_menu:
1027 1027 <span class="tooltip" title="Navigate to previous or next change inside files." id="diff_nav">Loading diff...:</span>
1028 1028 <span class="cursor-pointer" onclick="scrollToPrevChunk(); return false">
1029 1029 <i class="icon-angle-up"></i>
1030 1030 </span>
1031 1031 <span class="cursor-pointer" onclick="scrollToNextChunk(); return false">
1032 1032 <i class="icon-angle-down"></i>
1033 1033 </span>
1034 1034 % endif
1035 1035 </div>
1036 1036 <div class="sidebar_inner_shadow"></div>
1037 1037 </div>
1038 1038 </div>
1039 1039
1040 1040 % if diffset:
1041 1041 %if diffset.limited_diff:
1042 1042 <% file_placeholder = _ungettext('%(num)s file changed', '%(num)s files changed', diffset.changed_files) % {'num': diffset.changed_files} %>
1043 1043 %else:
1044 1044 <% file_placeholder = h.literal(_ungettext('%(num)s file changed: <span class="op-added">%(linesadd)s inserted</span>, <span class="op-deleted">%(linesdel)s deleted</span>', '%(num)s files changed: <span class="op-added">%(linesadd)s inserted</span>, <span class="op-deleted">%(linesdel)s deleted</span>',
1045 1045 diffset.changed_files) % {'num': diffset.changed_files, 'linesadd': diffset.lines_added, 'linesdel': diffset.lines_deleted}) %>
1046 1046
1047 1047 %endif
1048 1048 ## case on range-diff placeholder needs to be updated
1049 1049 % if range_diff_on is True:
1050 1050 <% file_placeholder = _('Disabled on range diff') %>
1051 1051 % endif
1052 1052
1053 1053 <script type="text/javascript">
1054 1054 var feedFilesOptions = function (query, initialData) {
1055 1055 var data = {results: []};
1056 1056 var isQuery = typeof query.term !== 'undefined';
1057 1057
1058 1058 var section = _gettext('Changed files');
1059 1059 var filteredData = [];
1060 1060
1061 1061 //filter results
1062 1062 $.each(initialData.results, function (idx, value) {
1063 1063
1064 1064 if (!isQuery || query.term.length === 0 || value.text.toUpperCase().indexOf(query.term.toUpperCase()) >= 0) {
1065 1065 filteredData.push({
1066 1066 'id': this.id,
1067 1067 'text': this.text,
1068 1068 "ops": this.ops,
1069 1069 })
1070 1070 }
1071 1071
1072 1072 });
1073 1073
1074 1074 data.results = filteredData;
1075 1075
1076 1076 query.callback(data);
1077 1077 };
1078 1078
1079 1079 var selectionFormatter = function(data, escapeMarkup) {
1080 1080 var container = '<div class="filelist" style="padding-right:100px">{0}</div>';
1081 1081 var tmpl = '<div><strong>{0}</strong></div>'.format(escapeMarkup(data['text']));
1082 1082 var pill = '<div class="pill-group" style="position: absolute; top:7px; right: 0">' +
1083 1083 '<span class="pill" op="added">{0}</span>' +
1084 1084 '<span class="pill" op="deleted">{1}</span>' +
1085 1085 '</div>'
1086 1086 ;
1087 1087 var added = data['ops']['added'];
1088 1088 if (added === 0) {
1089 1089 // don't show +0
1090 1090 added = 0;
1091 1091 } else {
1092 1092 added = '+' + added;
1093 1093 }
1094 1094
1095 1095 var deleted = -1*data['ops']['deleted'];
1096 1096
1097 1097 tmpl += pill.format(added, deleted);
1098 1098 return container.format(tmpl);
1099 1099 };
1100 1100 var formatFileResult = function(result, container, query, escapeMarkup) {
1101 1101 return selectionFormatter(result, escapeMarkup);
1102 1102 };
1103 1103
1104 1104 var formatSelection = function (data, container) {
1105 1105 return '${file_placeholder}'
1106 1106 };
1107 1107
1108 1108 if (window.preloadFileFilterData === undefined) {
1109 1109 window.preloadFileFilterData = {}
1110 1110 }
1111 1111
1112 1112 preloadFileFilterData["${diffset_container_id}"] = {
1113 1113 results: [
1114 1114 % for filediff in diffset.files:
1115 1115 {id:"a_${h.FID(filediff.raw_id, filediff.patch['filename'])}",
1116 1116 text:"${filediff.patch['filename']}",
1117 ops:${h.json.dumps(filediff.patch['stats'])|n}}${('' if loop.last else ',')}
1117 ops:${h.str_json(filediff.patch['stats'])|n}}${('' if loop.last else ',')}
1118 1118 % endfor
1119 1119 ]
1120 1120 };
1121 1121
1122 1122 var diffFileFilterId = "#file_filter_" + "${diffset_container_id}";
1123 1123 var diffFileFilter = $(diffFileFilterId).select2({
1124 1124 'dropdownAutoWidth': true,
1125 1125 'width': 'auto',
1126 1126
1127 1127 containerCssClass: "drop-menu",
1128 1128 dropdownCssClass: "drop-menu-dropdown",
1129 1129 data: preloadFileFilterData["${diffset_container_id}"],
1130 1130 query: function(query) {
1131 1131 feedFilesOptions(query, preloadFileFilterData["${diffset_container_id}"]);
1132 1132 },
1133 1133 initSelection: function(element, callback) {
1134 1134 callback({'init': true});
1135 1135 },
1136 1136 formatResult: formatFileResult,
1137 1137 formatSelection: formatSelection
1138 1138 });
1139 1139
1140 1140 % if range_diff_on is True:
1141 1141 diffFileFilter.select2("enable", false);
1142 1142 % endif
1143 1143
1144 1144 $(diffFileFilterId).on('select2-selecting', function (e) {
1145 1145 var idSelector = e.choice.id;
1146 1146
1147 1147 // expand the container if we quick-select the field
1148 1148 $('#'+idSelector).next().prop('checked', false);
1149 1149 // hide the mast as we later do preventDefault()
1150 1150 $("#select2-drop-mask").click();
1151 1151
1152 1152 window.location.hash = '#'+idSelector;
1153 1153 updateSticky();
1154 1154
1155 1155 e.preventDefault();
1156 1156 });
1157 1157
1158 1158 diffNavText = 'diff navigation:'
1159 1159
1160 1160 getCurrentChunk = function () {
1161 1161
1162 1162 var chunksAll = $('.nav-chunk').filter(function () {
1163 1163 return $(this).parents('.filediff').prev().get(0).checked !== true
1164 1164 })
1165 1165 var chunkSelected = $('.nav-chunk.selected');
1166 1166 var initial = false;
1167 1167
1168 1168 if (chunkSelected.length === 0) {
1169 1169 // no initial chunk selected, we pick first
1170 1170 chunkSelected = $(chunksAll.get(0));
1171 1171 var initial = true;
1172 1172 }
1173 1173
1174 1174 return {
1175 1175 'all': chunksAll,
1176 1176 'selected': chunkSelected,
1177 1177 'initial': initial,
1178 1178 }
1179 1179 }
1180 1180
1181 1181 animateDiffNavText = function () {
1182 1182 var $diffNav = $('#diff_nav')
1183 1183
1184 1184 var callback = function () {
1185 1185 $diffNav.animate({'opacity': 1.00}, 200)
1186 1186 };
1187 1187 $diffNav.animate({'opacity': 0.15}, 200, callback);
1188 1188 }
1189 1189
1190 1190 scrollToChunk = function (moveBy) {
1191 1191 var chunk = getCurrentChunk();
1192 1192 var all = chunk.all
1193 1193 var selected = chunk.selected
1194 1194
1195 1195 var curPos = all.index(selected);
1196 1196 var newPos = curPos;
1197 1197 if (!chunk.initial) {
1198 1198 var newPos = curPos + moveBy;
1199 1199 }
1200 1200
1201 1201 var curElem = all.get(newPos);
1202 1202
1203 1203 if (curElem === undefined) {
1204 1204 // end or back
1205 1205 $('#diff_nav').html('no next diff element:')
1206 1206 animateDiffNavText()
1207 1207 return
1208 1208 } else if (newPos < 0) {
1209 1209 $('#diff_nav').html('no previous diff element:')
1210 1210 animateDiffNavText()
1211 1211 return
1212 1212 } else {
1213 1213 $('#diff_nav').html(diffNavText)
1214 1214 }
1215 1215
1216 1216 curElem = $(curElem)
1217 1217 var offset = 100;
1218 1218 $(window).scrollTop(curElem.position().top - offset);
1219 1219
1220 1220 //clear selection
1221 1221 all.removeClass('selected')
1222 1222 curElem.addClass('selected')
1223 1223 }
1224 1224
1225 1225 scrollToPrevChunk = function () {
1226 1226 scrollToChunk(-1)
1227 1227 }
1228 1228 scrollToNextChunk = function () {
1229 1229 scrollToChunk(1)
1230 1230 }
1231 1231
1232 1232 </script>
1233 1233 % endif
1234 1234
1235 1235 <script type="text/javascript">
1236 1236 $('#diff_nav').html('loading diff...') // wait until whole page is loaded
1237 1237
1238 1238 $(document).ready(function () {
1239 1239
1240 1240 var contextPrefix = _gettext('Context file: ');
1241 1241 ## sticky sidebar
1242 1242 var sidebarElement = document.getElementById('diff-file-sticky');
1243 1243 sidebar = new StickySidebar(sidebarElement, {
1244 1244 topSpacing: 0,
1245 1245 bottomSpacing: 0,
1246 1246 innerWrapperSelector: '.sidebar__inner'
1247 1247 });
1248 1248 sidebarElement.addEventListener('affixed.static.stickySidebar', function () {
1249 1249 // reset our file so it's not holding new value
1250 1250 $('.fpath-placeholder-text').html(contextPrefix + ' - ')
1251 1251 });
1252 1252
1253 1253 updateSticky = function () {
1254 1254 sidebar.updateSticky();
1255 1255 Waypoint.refreshAll();
1256 1256 };
1257 1257
1258 1258 var animateText = function (fPath, anchorId) {
1259 1259 fPath = Select2.util.escapeMarkup(fPath);
1260 1260 $('.fpath-placeholder-text').html(contextPrefix + '<a href="#a_' + anchorId + '">' + fPath + '</a>')
1261 1261 };
1262 1262
1263 1263 ## dynamic file waypoints
1264 1264 var setFPathInfo = function(fPath, anchorId){
1265 1265 animateText(fPath, anchorId)
1266 1266 };
1267 1267
1268 1268 var codeBlock = $('.filediff');
1269 1269
1270 1270 // forward waypoint
1271 1271 codeBlock.waypoint(
1272 1272 function(direction) {
1273 1273 if (direction === "down"){
1274 1274 setFPathInfo($(this.element).data('fPath'), $(this.element).data('anchorId'))
1275 1275 }
1276 1276 }, {
1277 1277 offset: function () {
1278 1278 return 70;
1279 1279 },
1280 1280 context: '.fpath-placeholder'
1281 1281 }
1282 1282 );
1283 1283
1284 1284 // backward waypoint
1285 1285 codeBlock.waypoint(
1286 1286 function(direction) {
1287 1287 if (direction === "up"){
1288 1288 setFPathInfo($(this.element).data('fPath'), $(this.element).data('anchorId'))
1289 1289 }
1290 1290 }, {
1291 1291 offset: function () {
1292 1292 return -this.element.clientHeight + 90;
1293 1293 },
1294 1294 context: '.fpath-placeholder'
1295 1295 }
1296 1296 );
1297 1297
1298 1298 toggleWideDiff = function (el) {
1299 1299 updateSticky();
1300 1300 var wide = Rhodecode.comments.toggleWideMode(this);
1301 1301 storeUserSessionAttr('rc_user_session_attr.wide_diff_mode', wide);
1302 1302 if (wide === true) {
1303 1303 $(el).addClass('btn-active');
1304 1304 } else {
1305 1305 $(el).removeClass('btn-active');
1306 1306 }
1307 1307 return null;
1308 1308 };
1309 1309
1310 1310 toggleExpand = function (el, diffsetEl) {
1311 1311 var el = $(el);
1312 1312 if (el.hasClass('collapsed')) {
1313 1313 $('.filediff-collapse-state.collapse-{0}'.format(diffsetEl)).prop('checked', false);
1314 1314 el.removeClass('collapsed');
1315 1315 el.html(
1316 1316 '<i class="icon-minus-squared-alt icon-no-margin"></i>' +
1317 1317 _gettext('Collapse all files'));
1318 1318 }
1319 1319 else {
1320 1320 $('.filediff-collapse-state.collapse-{0}'.format(diffsetEl)).prop('checked', true);
1321 1321 el.addClass('collapsed');
1322 1322 el.html(
1323 1323 '<i class="icon-plus-squared-alt icon-no-margin"></i>' +
1324 1324 _gettext('Expand all files'));
1325 1325 }
1326 1326 updateSticky()
1327 1327 };
1328 1328
1329 1329 toggleCommitExpand = function (el) {
1330 1330 var $el = $(el);
1331 1331 var commits = $el.data('toggleCommitsCnt');
1332 1332 var collapseMsg = _ngettext('Collapse {0} commit', 'Collapse {0} commits', commits).format(commits);
1333 1333 var expandMsg = _ngettext('Expand {0} commit', 'Expand {0} commits', commits).format(commits);
1334 1334
1335 1335 if ($el.hasClass('collapsed')) {
1336 1336 $('.compare_select').show();
1337 1337 $('.compare_select_hidden').hide();
1338 1338
1339 1339 $el.removeClass('collapsed');
1340 1340 $el.html(
1341 1341 '<i class="icon-minus-squared-alt icon-no-margin"></i>' +
1342 1342 collapseMsg);
1343 1343 }
1344 1344 else {
1345 1345 $('.compare_select').hide();
1346 1346 $('.compare_select_hidden').show();
1347 1347 $el.addClass('collapsed');
1348 1348 $el.html(
1349 1349 '<i class="icon-plus-squared-alt icon-no-margin"></i>' +
1350 1350 expandMsg);
1351 1351 }
1352 1352 updateSticky();
1353 1353 };
1354 1354
1355 1355 // get stored diff mode and pre-enable it
1356 1356 if (templateContext.session_attrs.wide_diff_mode === "true") {
1357 1357 Rhodecode.comments.toggleWideMode(null);
1358 1358 $('.toggle-wide-diff').addClass('btn-active');
1359 1359 updateSticky();
1360 1360 }
1361 1361
1362 1362 // DIFF NAV //
1363 1363
1364 1364 // element to detect scroll direction of
1365 1365 var $window = $(window);
1366 1366
1367 1367 // initialize last scroll position
1368 1368 var lastScrollY = $window.scrollTop();
1369 1369
1370 1370 $window.on('resize scrollstop', {latency: 350}, function () {
1371 1371 var visibleChunks = $('.nav-chunk').withinviewport({top: 75});
1372 1372
1373 1373 // get current scroll position
1374 1374 var currentScrollY = $window.scrollTop();
1375 1375
1376 1376 // determine current scroll direction
1377 1377 if (currentScrollY > lastScrollY) {
1378 1378 var y = 'down'
1379 1379 } else if (currentScrollY !== lastScrollY) {
1380 1380 var y = 'up';
1381 1381 }
1382 1382
1383 1383 var pos = -1; // by default we use last element in viewport
1384 1384 if (y === 'down') {
1385 1385 pos = -1;
1386 1386 } else if (y === 'up') {
1387 1387 pos = 0;
1388 1388 }
1389 1389
1390 1390 if (visibleChunks.length > 0) {
1391 1391 $('.nav-chunk').removeClass('selected');
1392 1392 $(visibleChunks.get(pos)).addClass('selected');
1393 1393 }
1394 1394
1395 1395 // update last scroll position to current position
1396 1396 lastScrollY = currentScrollY;
1397 1397
1398 1398 });
1399 1399 $('#diff_nav').html(diffNavText);
1400 1400
1401 1401 });
1402 1402 </script>
1403 1403
1404 1404 </%def>
@@ -1,82 +1,82 b''
1 1
2 2 <div class="pull-request-wrap">
3 3
4 4 % if c.pr_merge_possible:
5 5 <h2 class="merge-status">
6 6 <span class="merge-icon success"><i class="icon-ok"></i></span>
7 7 ${_('This pull request can be merged automatically.')}
8 8 </h2>
9 9 % else:
10 10 <h2 class="merge-status">
11 11 <span class="merge-icon warning"><i class="icon-false"></i></span>
12 12 ${_('Merge is not currently possible because of below failed checks.')}
13 13 </h2>
14 14 % endif
15 15
16 16 % if c.pr_merge_errors.items():
17 17 <ul>
18 18 % for pr_check_key, pr_check_details in c.pr_merge_errors.items():
19 19 <% pr_check_type = pr_check_details['error_type'] %>
20 20 <li>
21 21 <div class="merge-message ${pr_check_type}" data-role="merge-message">
22 22 <span style="white-space: pre-line">- ${pr_check_details['message']}</span>
23 23 % if pr_check_key == 'todo':
24 24 % for co in pr_check_details['details']:
25 <a class="permalink" href="#comment-${co.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'), 0, ${h.json.dumps(co.outdated)})"> #${co.comment_id}</a>${'' if loop.last else ','}
25 <a class="permalink" href="#comment-${co.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'), 0, ${h.str_json(co.outdated)})"> #${co.comment_id}</a>${'' if loop.last else ','}
26 26 % endfor
27 27 % endif
28 28 </div>
29 29 </li>
30 30 % endfor
31 31 </ul>
32 32 % endif
33 33
34 34 <div class="pull-request-merge-actions">
35 35 % if c.allowed_to_merge:
36 36 ## Merge info, show only if all errors are taken care of
37 37 % if not c.pr_merge_errors and c.pr_merge_info:
38 38 <div class="pull-request-merge-info">
39 39 <ul>
40 40 % for pr_merge_key, pr_merge_details in c.pr_merge_info.items():
41 41 <li>
42 42 - ${pr_merge_details['message']}
43 43 </li>
44 44 % endfor
45 45 </ul>
46 46 </div>
47 47 % endif
48 48
49 49 <div>
50 50 ${h.secure_form(h.route_path('pullrequest_merge', repo_name=c.repo_name, pull_request_id=c.pull_request.pull_request_id), id='merge_pull_request_form', request=request)}
51 51 <% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
52 52
53 53 % if c.allowed_to_close:
54 54 ## close PR action, injected later next to COMMENT button
55 55 % if c.pull_request_review_status == c.REVIEW_STATUS_APPROVED:
56 56 <a id="close-pull-request-action" class="btn btn-approved-status" href="#close-as-approved" onclick="closePullRequest('${c.REVIEW_STATUS_APPROVED}'); return false;">
57 57 ${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_APPROVED))}
58 58 </a>
59 59 % else:
60 60 <a id="close-pull-request-action" class="btn btn-rejected-status" href="#close-as-rejected" onclick="closePullRequest('${c.REVIEW_STATUS_REJECTED}'); return false;">
61 61 ${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_REJECTED))}
62 62 </a>
63 63 % endif
64 64 % endif
65 65
66 66 <input type="submit" id="merge_pull_request" value="${_('Merge and close Pull Request')}" class="btn${merge_disabled}"${merge_disabled}>
67 67 ${h.end_form()}
68 68
69 69 <div class="pull-request-merge-refresh">
70 70 <a href="#refreshChecks" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
71 71 </div>
72 72
73 73 </div>
74 74 % elif c.rhodecode_user.username != h.DEFAULT_USER:
75 75 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
76 76 <input type="submit" value="${_('Merge and close Pull Request')}" class="btn disabled" disabled="disabled" title="${_('You are not allowed to merge this pull request.')}">
77 77 % else:
78 78 <input type="submit" value="${_('Login to Merge this Pull Request')}" class="btn disabled" disabled="disabled">
79 79 % endif
80 80 </div>
81 81
82 82 </div>
@@ -1,140 +1,139 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 urllib.parse
23 23 import mock
24 import simplejson as json
25 24
26 25 from rhodecode.lib.vcs.backends.base import Config
27 26 from rhodecode.tests.lib.middleware import mock_scm_app
28 27 import rhodecode.lib.middleware.simplegit as simplegit
29 28
30 29
31 30 def get_environ(url, request_method):
32 31 """Construct a minimum WSGI environ based on the URL."""
33 32 parsed_url = urllib.parse.urlparse(url)
34 33 environ = {
35 34 'PATH_INFO': parsed_url.path,
36 35 'QUERY_STRING': parsed_url.query,
37 36 'REQUEST_METHOD': request_method,
38 37 }
39 38
40 39 return environ
41 40
42 41
43 42 @pytest.mark.parametrize(
44 43 'url, expected_action, request_method',
45 44 [
46 45 ('/foo/bar/info/refs?service=git-upload-pack', 'pull', 'GET'),
47 46 ('/foo/bar/info/refs?service=git-receive-pack', 'push', 'GET'),
48 47 ('/foo/bar/git-upload-pack', 'pull', 'GET'),
49 48 ('/foo/bar/git-receive-pack', 'push', 'GET'),
50 49 # Edge case: missing data for info/refs
51 50 ('/foo/info/refs?service=', 'pull', 'GET'),
52 51 ('/foo/info/refs', 'pull', 'GET'),
53 52 # Edge case: git command comes with service argument
54 53 ('/foo/git-upload-pack?service=git-receive-pack', 'pull', 'GET'),
55 54 ('/foo/git-receive-pack?service=git-upload-pack', 'push', 'GET'),
56 55 # Edge case: repo name conflicts with git commands
57 56 ('/git-receive-pack/git-upload-pack', 'pull', 'GET'),
58 57 ('/git-receive-pack/git-receive-pack', 'push', 'GET'),
59 58 ('/git-upload-pack/git-upload-pack', 'pull', 'GET'),
60 59 ('/git-upload-pack/git-receive-pack', 'push', 'GET'),
61 60 ('/foo/git-receive-pack', 'push', 'GET'),
62 61 # Edge case: not a smart protocol url
63 62 ('/foo/bar', 'pull', 'GET'),
64 63 # GIT LFS cases, batch
65 64 ('/foo/bar/info/lfs/objects/batch', 'push', 'GET'),
66 65 ('/foo/bar/info/lfs/objects/batch', 'pull', 'POST'),
67 66 # GIT LFS oid, dl/upl
68 67 ('/foo/bar/info/lfs/abcdeabcde', 'pull', 'GET'),
69 68 ('/foo/bar/info/lfs/abcdeabcde', 'push', 'PUT'),
70 69 ('/foo/bar/info/lfs/abcdeabcde', 'push', 'POST'),
71 70 # Edge case: repo name conflicts with git commands
72 71 ('/info/lfs/info/lfs/objects/batch', 'push', 'GET'),
73 72 ('/info/lfs/info/lfs/objects/batch', 'pull', 'POST'),
74 73
75 74 ])
76 75 def test_get_action(url, expected_action, request_method, baseapp, request_stub):
77 76 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
78 77 registry=request_stub.registry)
79 78 assert expected_action == app._get_action(get_environ(url, request_method))
80 79
81 80
82 81 @pytest.mark.parametrize(
83 82 'url, expected_repo_name, request_method',
84 83 [
85 84 ('/foo/info/refs?service=git-upload-pack', 'foo', 'GET'),
86 85 ('/foo/bar/info/refs?service=git-receive-pack', 'foo/bar', 'GET'),
87 86 ('/foo/git-upload-pack', 'foo', 'GET'),
88 87 ('/foo/git-receive-pack', 'foo', 'GET'),
89 88 ('/foo/bar/git-upload-pack', 'foo/bar', 'GET'),
90 89 ('/foo/bar/git-receive-pack', 'foo/bar', 'GET'),
91 90
92 91 # GIT LFS cases, batch
93 92 ('/foo/bar/info/lfs/objects/batch', 'foo/bar', 'GET'),
94 93 ('/example-git/info/lfs/objects/batch', 'example-git', 'POST'),
95 94 # GIT LFS oid, dl/upl
96 95 ('/foo/info/lfs/abcdeabcde', 'foo', 'GET'),
97 96 ('/foo/bar/info/lfs/abcdeabcde', 'foo/bar', 'PUT'),
98 97 ('/my-git-repo/info/lfs/abcdeabcde', 'my-git-repo', 'POST'),
99 98 # Edge case: repo name conflicts with git commands
100 99 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'GET'),
101 100 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'POST'),
102 101
103 102 ])
104 103 def test_get_repository_name(url, expected_repo_name, request_method, baseapp, request_stub):
105 104 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
106 105 registry=request_stub.registry)
107 106 assert expected_repo_name == app._get_repository_name(
108 107 get_environ(url, request_method))
109 108
110 109
111 110 def test_get_config(user_util, baseapp, request_stub):
112 111 repo = user_util.create_repo(repo_type='git')
113 112 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
114 113 registry=request_stub.registry)
115 114 extras = {'foo': 'FOO', 'bar': 'BAR'}
116 115
117 116 # We copy the extras as the method below will change the contents.
118 117 git_config = app._create_config(dict(extras), repo_name=repo.repo_name)
119 118
120 119 expected_config = dict(extras)
121 120 expected_config.update({
122 121 'git_update_server_info': False,
123 122 'git_lfs_enabled': False,
124 123 'git_lfs_store_path': git_config['git_lfs_store_path'],
125 124 'git_lfs_http_scheme': 'http'
126 125 })
127 126
128 127 assert git_config == expected_config
129 128
130 129
131 130 def test_create_wsgi_app_uses_scm_app_from_simplevcs(baseapp, request_stub):
132 131 config = {
133 132 'auth_ret_code': '',
134 133 'base_path': '',
135 134 'vcs.scm_app_implementation':
136 135 'rhodecode.tests.lib.middleware.mock_scm_app',
137 136 }
138 137 app = simplegit.SimpleGit(config=config, registry=request_stub.registry)
139 138 wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {})
140 139 assert wsgi_app is mock_scm_app.mock_git_wsgi
General Comments 0
You need to be logged in to leave comments. Login now