##// END OF EJS Templates
helpers: fix tests and tag replacers
super-admin -
r5152:f352498b default
parent child Browse files
Show More
@@ -1,2198 +1,2199 b''
1 1 # Copyright (C) 2010-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 Helper functions
21 21
22 22 Consists of functions to typically be used within templates, but also
23 23 available to Controllers. This module is available to both as 'h'.
24 24 """
25 25 import base64
26 26 import collections
27 27
28 28 import os
29 29 import random
30 30 import hashlib
31 31 import io
32 32 import textwrap
33 33 import urllib.request
34 34 import urllib.parse
35 35 import 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 regex
42 42 from collections import OrderedDict
43 43
44 44 import pygments
45 45 import itertools
46 46 import fnmatch
47 47
48 48 from datetime import datetime
49 49 from functools import partial
50 50 from pygments.formatters.html import HtmlFormatter
51 51 from pygments.lexers import (
52 52 get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype)
53 53
54 54 from pyramid.threadlocal import get_current_request
55 55 from tempita import looper
56 56 from webhelpers2.html import literal, HTML, escape
57 57 from webhelpers2.html._autolink import _auto_link_urls
58 58 from webhelpers2.html.tools import (
59 59 button_to, highlight, js_obfuscate, strip_links, strip_tags)
60 60
61 61 from webhelpers2.text import (
62 62 chop_at, collapse, convert_accented_entities,
63 63 convert_misc_entities, lchop, plural, rchop, remove_formatting,
64 64 replace_whitespace, urlify, truncate, wrap_paragraphs)
65 65 from webhelpers2.date import time_ago_in_words
66 66
67 67 from webhelpers2.html.tags import (
68 68 _input, NotGiven, _make_safe_id_component as safeid,
69 69 form as insecure_form,
70 70 auto_discovery_link, checkbox, end_form, file,
71 71 hidden, image, javascript_link, link_to, link_to_if, link_to_unless, ol,
72 72 stylesheet_link, submit, text, password, textarea,
73 73 ul, radio, Options)
74 74
75 75 from webhelpers2.number import format_byte_size
76 76 # python3.11 backport fixes for webhelpers2
77 77 from rhodecode.lib._vendor.webhelpers_backports import raw_select
78 78
79 79 from rhodecode.lib.action_parser import action_parser
80 80 from rhodecode.lib.html_filters import sanitize_html
81 81 from rhodecode.lib.pagination import Page, RepoPage, SqlPage
82 82 from rhodecode.lib import ext_json
83 83 from rhodecode.lib.ext_json import json
84 84 from rhodecode.lib.str_utils import safe_bytes, convert_special_chars, base64_to_str
85 85 from rhodecode.lib.utils import repo_name_slug, get_custom_lexer
86 86 from rhodecode.lib.str_utils import safe_str
87 87 from rhodecode.lib.utils2 import (
88 88 str2bool,
89 89 get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime,
90 90 AttributeDict, safe_int, md5, md5_safe, get_host_info)
91 91 from rhodecode.lib.markup_renderer import MarkupRenderer, relative_links
92 92 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
93 93 from rhodecode.lib.vcs.backends.base import BaseChangeset, EmptyCommit
94 94 from rhodecode.lib.vcs.conf.settings import ARCHIVE_SPECS
95 95 from rhodecode.lib.index.search_utils import get_matching_line_offsets
96 96 from rhodecode.config.conf import DATE_FORMAT, DATETIME_FORMAT
97 97 from rhodecode.model.changeset_status import ChangesetStatusModel
98 98 from rhodecode.model.db import Permission, User, Repository, UserApiKeys, FileStore
99 99 from rhodecode.model.repo_group import RepoGroupModel
100 100 from rhodecode.model.settings import IssueTrackerSettingsModel
101 101
102 102
103 103 log = logging.getLogger(__name__)
104 104
105 105
106 106 DEFAULT_USER = User.DEFAULT_USER
107 107 DEFAULT_USER_EMAIL = User.DEFAULT_USER_EMAIL
108 108
109 109
110 110 def asset(path, ver=None, **kwargs):
111 111 """
112 112 Helper to generate a static asset file path for rhodecode assets
113 113
114 114 eg. h.asset('images/image.png', ver='3923')
115 115
116 116 :param path: path of asset
117 117 :param ver: optional version query param to append as ?ver=
118 118 """
119 119 request = get_current_request()
120 120 query = {}
121 121 query.update(kwargs)
122 122 if ver:
123 123 query = {'ver': ver}
124 124 return request.static_path(
125 125 f'rhodecode:public/{path}', _query=query)
126 126
127 127
128 128 default_html_escape_table = {
129 129 ord('&'): '&amp;',
130 130 ord('<'): '&lt;',
131 131 ord('>'): '&gt;',
132 132 ord('"'): '&quot;',
133 133 ord("'"): '&#39;',
134 134 }
135 135
136 136
137 137 def html_escape(text, html_escape_table=default_html_escape_table):
138 138 """Produce entities within text."""
139 139 return text.translate(html_escape_table)
140 140
141 141
142 142 def str_json(*args, **kwargs):
143 143 return ext_json.str_json(*args, **kwargs)
144 144
145 145
146 146 def formatted_str_json(*args, **kwargs):
147 147 return ext_json.formatted_str_json(*args, **kwargs)
148 148
149 149
150 150 def chop_at_smart(s, sub, inclusive=False, suffix_if_chopped=None):
151 151 """
152 152 Truncate string ``s`` at the first occurrence of ``sub``.
153 153
154 154 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
155 155 """
156 156 suffix_if_chopped = suffix_if_chopped or ''
157 157 pos = s.find(sub)
158 158 if pos == -1:
159 159 return s
160 160
161 161 if inclusive:
162 162 pos += len(sub)
163 163
164 164 chopped = s[:pos]
165 165 left = s[pos:].strip()
166 166
167 167 if left and suffix_if_chopped:
168 168 chopped += suffix_if_chopped
169 169
170 170 return chopped
171 171
172 172
173 173 def shorter(text, size=20, prefix=False):
174 174 postfix = '...'
175 175 if len(text) > size:
176 176 if prefix:
177 177 # shorten in front
178 178 return postfix + text[-(size - len(postfix)):]
179 179 else:
180 180 return text[:size - len(postfix)] + postfix
181 181 return text
182 182
183 183
184 184 def reset(name, value=None, id=NotGiven, type="reset", **attrs):
185 185 """
186 186 Reset button
187 187 """
188 188 return _input(type, name, value, id, attrs)
189 189
190 190
191 191 def select(name, selected_values, options, id=NotGiven, **attrs):
192 192
193 193 if isinstance(options, (list, tuple)):
194 194 options_iter = options
195 195 # Handle old value,label lists ... where value also can be value,label lists
196 196 options = Options()
197 197 for opt in options_iter:
198 198 if isinstance(opt, tuple) and len(opt) == 2:
199 199 value, label = opt
200 200 elif isinstance(opt, str):
201 201 value = label = opt
202 202 else:
203 203 raise ValueError('invalid select option type %r' % type(opt))
204 204
205 205 if isinstance(value, (list, tuple)):
206 206 option_group = options.add_optgroup(label)
207 207 for opt2 in value:
208 208 if isinstance(opt2, tuple) and len(opt2) == 2:
209 209 group_value, group_label = opt2
210 210 elif isinstance(opt2, str):
211 211 group_value = group_label = opt2
212 212 else:
213 213 raise ValueError('invalid select option type %r' % type(opt2))
214 214
215 215 option_group.add_option(group_label, group_value)
216 216 else:
217 217 options.add_option(label, value)
218 218
219 219 return raw_select(name, selected_values, options, id=id, **attrs)
220 220
221 221
222 222 def branding(name, length=40):
223 223 return truncate(name, length, indicator="")
224 224
225 225
226 226 def FID(raw_id, path):
227 227 """
228 228 Creates a unique ID for filenode based on it's hash of path and commit
229 229 it's safe to use in urls
230 230
231 231 :param raw_id:
232 232 :param path:
233 233 """
234 234
235 235 return 'c-%s-%s' % (short_id(raw_id), md5_safe(path)[:12])
236 236
237 237
238 238 class _GetError(object):
239 239 """Get error from form_errors, and represent it as span wrapped error
240 240 message
241 241
242 242 :param field_name: field to fetch errors for
243 243 :param form_errors: form errors dict
244 244 """
245 245
246 246 def __call__(self, field_name, form_errors):
247 247 tmpl = """<span class="error_msg">%s</span>"""
248 248 if form_errors and field_name in form_errors:
249 249 return literal(tmpl % form_errors.get(field_name))
250 250
251 251
252 252 get_error = _GetError()
253 253
254 254
255 255 class _ToolTip(object):
256 256
257 257 def __call__(self, tooltip_title, trim_at=50):
258 258 """
259 259 Special function just to wrap our text into nice formatted
260 260 autowrapped text
261 261
262 262 :param tooltip_title:
263 263 """
264 264 tooltip_title = escape(tooltip_title)
265 265 tooltip_title = tooltip_title.replace('<', '&lt;').replace('>', '&gt;')
266 266 return tooltip_title
267 267
268 268
269 269 tooltip = _ToolTip()
270 270
271 271 files_icon = '<i class="file-breadcrumb-copy tooltip icon-clipboard clipboard-action" data-clipboard-text="{}" title="Copy file path"></i>'
272 272
273 273
274 274 def files_breadcrumbs(repo_name, repo_type, commit_id, file_path, landing_ref_name=None, at_ref=None,
275 275 limit_items=False, linkify_last_item=False, hide_last_item=False,
276 276 copy_path_icon=True):
277 277
278 278 if at_ref:
279 279 route_qry = {'at': at_ref}
280 280 default_landing_ref = at_ref or landing_ref_name or commit_id
281 281 else:
282 282 route_qry = None
283 283 default_landing_ref = commit_id
284 284
285 285 # first segment is a `HOME` link to repo files root location
286 286 root_name = literal('<i class="icon-home"></i>')
287 287
288 288 url_segments = [
289 289 link_to(
290 290 root_name,
291 291 repo_files_by_ref_url(
292 292 repo_name,
293 293 repo_type,
294 294 f_path=None, # None here is a special case for SVN repos,
295 295 # that won't prefix with a ref
296 296 ref_name=default_landing_ref,
297 297 commit_id=commit_id,
298 298 query=route_qry
299 299 )
300 300 )]
301 301
302 302 path_segments = file_path.split('/')
303 303 last_cnt = len(path_segments) - 1
304 304 for cnt, segment in enumerate(path_segments):
305 305 if not segment:
306 306 continue
307 307 segment_html = escape(segment)
308 308
309 309 last_item = cnt == last_cnt
310 310
311 311 if last_item and hide_last_item:
312 312 # iterate over and hide last element
313 313 continue
314 314
315 315 if last_item and linkify_last_item is False:
316 316 # plain version
317 317 url_segments.append(segment_html)
318 318 else:
319 319 url_segments.append(
320 320 link_to(
321 321 segment_html,
322 322 repo_files_by_ref_url(
323 323 repo_name,
324 324 repo_type,
325 325 f_path='/'.join(path_segments[:cnt + 1]),
326 326 ref_name=default_landing_ref,
327 327 commit_id=commit_id,
328 328 query=route_qry
329 329 ),
330 330 ))
331 331
332 332 limited_url_segments = url_segments[:1] + ['...'] + url_segments[-5:]
333 333 if limit_items and len(limited_url_segments) < len(url_segments):
334 334 url_segments = limited_url_segments
335 335
336 336 full_path = file_path
337 337 if copy_path_icon:
338 338 icon = files_icon.format(escape(full_path))
339 339 else:
340 340 icon = ''
341 341
342 342 if file_path == '':
343 343 return root_name
344 344 else:
345 345 return literal(' / '.join(url_segments) + icon)
346 346
347 347
348 348 def files_url_data(request):
349 349 matchdict = request.matchdict
350 350
351 351 if 'f_path' not in matchdict:
352 352 matchdict['f_path'] = ''
353 353 else:
354 354 matchdict['f_path'] = urllib.parse.quote(safe_str(matchdict['f_path']))
355 355 if 'commit_id' not in matchdict:
356 356 matchdict['commit_id'] = 'tip'
357 357
358 358 return ext_json.str_json(matchdict)
359 359
360 360
361 361 def repo_files_by_ref_url(db_repo_name, db_repo_type, f_path, ref_name, commit_id, query=None, ):
362 362 _is_svn = is_svn(db_repo_type)
363 363 final_f_path = f_path
364 364
365 365 if _is_svn:
366 366 """
367 367 For SVN the ref_name cannot be used as a commit_id, it needs to be prefixed with
368 368 actually commit_id followed by the ref_name. This should be done only in case
369 369 This is a initial landing url, without additional paths.
370 370
371 371 like: /1000/tags/1.0.0/?at=tags/1.0.0
372 372 """
373 373
374 374 if ref_name and ref_name != 'tip':
375 375 # NOTE(marcink): for svn the ref_name is actually the stored path, so we prefix it
376 376 # for SVN we only do this magic prefix if it's root, .eg landing revision
377 377 # of files link. If we are in the tree we don't need this since we traverse the url
378 378 # that has everything stored
379 379 if f_path in ['', '/']:
380 380 final_f_path = '/'.join([ref_name, f_path])
381 381
382 382 # SVN always needs a commit_id explicitly, without a named REF
383 383 default_commit_id = commit_id
384 384 else:
385 385 """
386 386 For git and mercurial we construct a new URL using the names instead of commit_id
387 387 like: /master/some_path?at=master
388 388 """
389 389 # We currently do not support branches with slashes
390 390 if '/' in ref_name:
391 391 default_commit_id = commit_id
392 392 else:
393 393 default_commit_id = ref_name
394 394
395 395 # sometimes we pass f_path as None, to indicate explicit no prefix,
396 396 # we translate it to string to not have None
397 397 final_f_path = final_f_path or ''
398 398
399 399 files_url = route_path(
400 400 'repo_files',
401 401 repo_name=db_repo_name,
402 402 commit_id=default_commit_id,
403 403 f_path=final_f_path,
404 404 _query=query
405 405 )
406 406 return files_url
407 407
408 408
409 409 def code_highlight(code, lexer, formatter, use_hl_filter=False):
410 410 """
411 411 Lex ``code`` with ``lexer`` and format it with the formatter ``formatter``.
412 412
413 413 If ``outfile`` is given and a valid file object (an object
414 414 with a ``write`` method), the result will be written to it, otherwise
415 415 it is returned as a string.
416 416 """
417 417 if use_hl_filter:
418 418 # add HL filter
419 419 from rhodecode.lib.index import search_utils
420 420 lexer.add_filter(search_utils.ElasticSearchHLFilter())
421 421 return pygments.format(pygments.lex(code, lexer), formatter)
422 422
423 423
424 424 class CodeHtmlFormatter(HtmlFormatter):
425 425 """
426 426 My code Html Formatter for source codes
427 427 """
428 428
429 429 def wrap(self, source):
430 430 return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
431 431
432 432 def _wrap_code(self, source):
433 433 for cnt, it in enumerate(source):
434 434 i, t = it
435 435 t = f'<div id="L{cnt+1}">{t}</div>'
436 436 yield i, t
437 437
438 438 def _wrap_tablelinenos(self, inner):
439 439 dummyoutfile = io.StringIO()
440 440 lncount = 0
441 441 for t, line in inner:
442 442 if t:
443 443 lncount += 1
444 444 dummyoutfile.write(line)
445 445
446 446 fl = self.linenostart
447 447 mw = len(str(lncount + fl - 1))
448 448 sp = self.linenospecial
449 449 st = self.linenostep
450 450 la = self.lineanchors
451 451 aln = self.anchorlinenos
452 452 nocls = self.noclasses
453 453 if sp:
454 454 lines = []
455 455
456 456 for i in range(fl, fl + lncount):
457 457 if i % st == 0:
458 458 if i % sp == 0:
459 459 if aln:
460 460 lines.append('<a href="#%s%d" class="special">%*d</a>' %
461 461 (la, i, mw, i))
462 462 else:
463 463 lines.append('<span class="special">%*d</span>' % (mw, i))
464 464 else:
465 465 if aln:
466 466 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
467 467 else:
468 468 lines.append('%*d' % (mw, i))
469 469 else:
470 470 lines.append('')
471 471 ls = '\n'.join(lines)
472 472 else:
473 473 lines = []
474 474 for i in range(fl, fl + lncount):
475 475 if i % st == 0:
476 476 if aln:
477 477 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
478 478 else:
479 479 lines.append('%*d' % (mw, i))
480 480 else:
481 481 lines.append('')
482 482 ls = '\n'.join(lines)
483 483
484 484 # in case you wonder about the seemingly redundant <div> here: since the
485 485 # content in the other cell also is wrapped in a div, some browsers in
486 486 # some configurations seem to mess up the formatting...
487 487 if nocls:
488 488 yield 0, ('<table class="%stable">' % self.cssclass +
489 489 '<tr><td><div class="linenodiv" '
490 490 'style="background-color: #f0f0f0; padding-right: 10px">'
491 491 '<pre style="line-height: 125%">' +
492 492 ls + '</pre></div></td><td id="hlcode" class="code">')
493 493 else:
494 494 yield 0, ('<table class="%stable">' % self.cssclass +
495 495 '<tr><td class="linenos"><div class="linenodiv"><pre>' +
496 496 ls + '</pre></div></td><td id="hlcode" class="code">')
497 497 yield 0, dummyoutfile.getvalue()
498 498 yield 0, '</td></tr></table>'
499 499
500 500
501 501 class SearchContentCodeHtmlFormatter(CodeHtmlFormatter):
502 502 def __init__(self, **kw):
503 503 # only show these line numbers if set
504 504 self.only_lines = kw.pop('only_line_numbers', [])
505 505 self.query_terms = kw.pop('query_terms', [])
506 506 self.max_lines = kw.pop('max_lines', 5)
507 507 self.line_context = kw.pop('line_context', 3)
508 508 self.url = kw.pop('url', None)
509 509
510 510 super(CodeHtmlFormatter, self).__init__(**kw)
511 511
512 512 def _wrap_code(self, source):
513 513 for cnt, it in enumerate(source):
514 514 i, t = it
515 515 t = '<pre>%s</pre>' % t
516 516 yield i, t
517 517
518 518 def _wrap_tablelinenos(self, inner):
519 519 yield 0, '<table class="code-highlight %stable">' % self.cssclass
520 520
521 521 last_shown_line_number = 0
522 522 current_line_number = 1
523 523
524 524 for t, line in inner:
525 525 if not t:
526 526 yield t, line
527 527 continue
528 528
529 529 if current_line_number in self.only_lines:
530 530 if last_shown_line_number + 1 != current_line_number:
531 531 yield 0, '<tr>'
532 532 yield 0, '<td class="line">...</td>'
533 533 yield 0, '<td id="hlcode" class="code"></td>'
534 534 yield 0, '</tr>'
535 535
536 536 yield 0, '<tr>'
537 537 if self.url:
538 538 yield 0, '<td class="line"><a href="%s#L%i">%i</a></td>' % (
539 539 self.url, current_line_number, current_line_number)
540 540 else:
541 541 yield 0, '<td class="line"><a href="">%i</a></td>' % (
542 542 current_line_number)
543 543 yield 0, '<td id="hlcode" class="code">' + line + '</td>'
544 544 yield 0, '</tr>'
545 545
546 546 last_shown_line_number = current_line_number
547 547
548 548 current_line_number += 1
549 549
550 550 yield 0, '</table>'
551 551
552 552
553 553 def hsv_to_rgb(h, s, v):
554 554 """ Convert hsv color values to rgb """
555 555
556 556 if s == 0.0:
557 557 return v, v, v
558 558 i = int(h * 6.0) # XXX assume int() truncates!
559 559 f = (h * 6.0) - i
560 560 p = v * (1.0 - s)
561 561 q = v * (1.0 - s * f)
562 562 t = v * (1.0 - s * (1.0 - f))
563 563 i = i % 6
564 564 if i == 0:
565 565 return v, t, p
566 566 if i == 1:
567 567 return q, v, p
568 568 if i == 2:
569 569 return p, v, t
570 570 if i == 3:
571 571 return p, q, v
572 572 if i == 4:
573 573 return t, p, v
574 574 if i == 5:
575 575 return v, p, q
576 576
577 577
578 578 def unique_color_generator(n=10000, saturation=0.10, lightness=0.95):
579 579 """
580 580 Generator for getting n of evenly distributed colors using
581 581 hsv color and golden ratio. It always return same order of colors
582 582
583 583 :param n: number of colors to generate
584 584 :param saturation: saturation of returned colors
585 585 :param lightness: lightness of returned colors
586 586 :returns: RGB tuple
587 587 """
588 588
589 589 golden_ratio = 0.618033988749895
590 590 h = 0.22717784590367374
591 591
592 592 for _ in range(n):
593 593 h += golden_ratio
594 594 h %= 1
595 595 HSV_tuple = [h, saturation, lightness]
596 596 RGB_tuple = hsv_to_rgb(*HSV_tuple)
597 597 yield [str(int(x * 256)) for x in RGB_tuple]
598 598
599 599
600 600 def color_hasher(n=10000, saturation=0.10, lightness=0.95):
601 601 """
602 602 Returns a function which when called with an argument returns a unique
603 603 color for that argument, eg.
604 604
605 605 :param n: number of colors to generate
606 606 :param saturation: saturation of returned colors
607 607 :param lightness: lightness of returned colors
608 608 :returns: css RGB string
609 609
610 610 >>> color_hash = color_hasher()
611 611 >>> color_hash('hello')
612 612 'rgb(34, 12, 59)'
613 613 >>> color_hash('hello')
614 614 'rgb(34, 12, 59)'
615 615 >>> color_hash('other')
616 616 'rgb(90, 224, 159)'
617 617 """
618 618
619 619 color_dict = {}
620 620 cgenerator = unique_color_generator(
621 621 saturation=saturation, lightness=lightness)
622 622
623 623 def get_color_string(thing):
624 624 if thing in color_dict:
625 625 col = color_dict[thing]
626 626 else:
627 627 col = color_dict[thing] = next(cgenerator)
628 628 return "rgb(%s)" % (', '.join(col))
629 629
630 630 return get_color_string
631 631
632 632
633 633 def get_lexer_safe(mimetype=None, filepath=None):
634 634 """
635 635 Tries to return a relevant pygments lexer using mimetype/filepath name,
636 636 defaulting to plain text if none could be found
637 637 """
638 638 lexer = None
639 639 try:
640 640 if mimetype:
641 641 lexer = get_lexer_for_mimetype(mimetype)
642 642 if not lexer:
643 643 lexer = get_lexer_for_filename(filepath)
644 644 except pygments.util.ClassNotFound:
645 645 pass
646 646
647 647 if not lexer:
648 648 lexer = get_lexer_by_name('text')
649 649
650 650 return lexer
651 651
652 652
653 653 def get_lexer_for_filenode(filenode):
654 654 lexer = get_custom_lexer(filenode.extension) or filenode.lexer
655 655 return lexer
656 656
657 657
658 658 def pygmentize(filenode, **kwargs):
659 659 """
660 660 pygmentize function using pygments
661 661
662 662 :param filenode:
663 663 """
664 664 lexer = get_lexer_for_filenode(filenode)
665 665 return literal(code_highlight(filenode.content, lexer,
666 666 CodeHtmlFormatter(**kwargs)))
667 667
668 668
669 669 def is_following_repo(repo_name, user_id):
670 670 from rhodecode.model.scm import ScmModel
671 671 return ScmModel().is_following_repo(repo_name, user_id)
672 672
673 673
674 674 class _Message(object):
675 675 """A message returned by ``Flash.pop_messages()``.
676 676
677 677 Converting the message to a string returns the message text. Instances
678 678 also have the following attributes:
679 679
680 680 * ``message``: the message text.
681 681 * ``category``: the category specified when the message was created.
682 682 """
683 683
684 684 def __init__(self, category, message, sub_data=None):
685 685 self.category = category
686 686 self.message = message
687 687 self.sub_data = sub_data or {}
688 688
689 689 def __str__(self):
690 690 return self.message
691 691
692 692 __unicode__ = __str__
693 693
694 694 def __html__(self):
695 695 return escape(safe_str(self.message))
696 696
697 697
698 698 class Flash(object):
699 699 # List of allowed categories. If None, allow any category.
700 700 categories = ["warning", "notice", "error", "success"]
701 701
702 702 # Default category if none is specified.
703 703 default_category = "notice"
704 704
705 705 def __init__(self, session_key="flash", categories=None,
706 706 default_category=None):
707 707 """
708 708 Instantiate a ``Flash`` object.
709 709
710 710 ``session_key`` is the key to save the messages under in the user's
711 711 session.
712 712
713 713 ``categories`` is an optional list which overrides the default list
714 714 of categories.
715 715
716 716 ``default_category`` overrides the default category used for messages
717 717 when none is specified.
718 718 """
719 719 self.session_key = session_key
720 720 if categories is not None:
721 721 self.categories = categories
722 722 if default_category is not None:
723 723 self.default_category = default_category
724 724 if self.categories and self.default_category not in self.categories:
725 725 raise ValueError(
726 726 "unrecognized default category %r" % (self.default_category,))
727 727
728 728 def pop_messages(self, session=None, request=None):
729 729 """
730 730 Return all accumulated messages and delete them from the session.
731 731
732 732 The return value is a list of ``Message`` objects.
733 733 """
734 734 messages = []
735 735
736 736 if not session:
737 737 if not request:
738 738 request = get_current_request()
739 739 session = request.session
740 740
741 741 # Pop the 'old' pylons flash messages. They are tuples of the form
742 742 # (category, message)
743 743 for cat, msg in session.pop(self.session_key, []):
744 744 messages.append(_Message(cat, msg))
745 745
746 746 # Pop the 'new' pyramid flash messages for each category as list
747 747 # of strings.
748 748 for cat in self.categories:
749 749 for msg in session.pop_flash(queue=cat):
750 750 sub_data = {}
751 751 if hasattr(msg, 'rsplit'):
752 752 flash_data = msg.rsplit('|DELIM|', 1)
753 753 org_message = flash_data[0]
754 754 if len(flash_data) > 1:
755 755 sub_data = json.loads(flash_data[1])
756 756 else:
757 757 org_message = msg
758 758
759 759 messages.append(_Message(cat, org_message, sub_data=sub_data))
760 760
761 761 # Map messages from the default queue to the 'notice' category.
762 762 for msg in session.pop_flash():
763 763 messages.append(_Message('notice', msg))
764 764
765 765 session.save()
766 766 return messages
767 767
768 768 def json_alerts(self, session=None, request=None):
769 769 payloads = []
770 770 messages = flash.pop_messages(session=session, request=request) or []
771 771 for message in messages:
772 772 payloads.append({
773 773 'message': {
774 774 'message': '{}'.format(message.message),
775 775 'level': message.category,
776 776 'force': True,
777 777 'subdata': message.sub_data
778 778 }
779 779 })
780 780 return safe_str(json.dumps(payloads))
781 781
782 782 def __call__(self, message, category=None, ignore_duplicate=True,
783 783 session=None, request=None):
784 784
785 785 if not session:
786 786 if not request:
787 787 request = get_current_request()
788 788 session = request.session
789 789
790 790 session.flash(
791 791 message, queue=category, allow_duplicate=not ignore_duplicate)
792 792
793 793
794 794 flash = Flash()
795 795
796 796 #==============================================================================
797 797 # SCM FILTERS available via h.
798 798 #==============================================================================
799 799 from rhodecode.lib.vcs.utils import author_name, author_email
800 800 from rhodecode.lib.utils2 import age, age_from_seconds
801 801 from rhodecode.model.db import User, ChangesetStatus
802 802
803 803
804 804 email = author_email
805 805
806 806
807 807 def capitalize(raw_text):
808 808 return raw_text.capitalize()
809 809
810 810
811 811 def short_id(long_id):
812 812 return long_id[:12]
813 813
814 814
815 815 def hide_credentials(url):
816 816 from rhodecode.lib.utils2 import credentials_filter
817 817 return credentials_filter(url)
818 818
819 819 import zoneinfo
820 820 import tzlocal
821 821 local_timezone = tzlocal.get_localzone()
822 822
823 823
824 824 def get_timezone(datetime_iso, time_is_local=False):
825 825 tzinfo = '+00:00'
826 826
827 827 # detect if we have a timezone info, otherwise, add it
828 828 if time_is_local and isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo:
829 829 force_timezone = os.environ.get('RC_TIMEZONE', '')
830 830 if force_timezone:
831 831 force_timezone = zoneinfo.ZoneInfo(force_timezone)
832 832 timezone = force_timezone or local_timezone
833 833
834 834 offset = datetime_iso.replace(tzinfo=timezone).strftime('%z')
835 835 tzinfo = '{}:{}'.format(offset[:-2], offset[-2:])
836 836 return tzinfo
837 837
838 838
839 839 def age_component(datetime_iso, value=None, time_is_local=False, tooltip=True):
840 840 title = value or format_date(datetime_iso)
841 841 tzinfo = get_timezone(datetime_iso, time_is_local=time_is_local)
842 842
843 843 return literal(
844 844 '<time class="timeago {cls}" title="{tt_title}" datetime="{dt}{tzinfo}">{title}</time>'.format(
845 845 cls='tooltip' if tooltip else '',
846 846 tt_title=('{title}{tzinfo}'.format(title=title, tzinfo=tzinfo)) if tooltip else '',
847 847 title=title, dt=datetime_iso, tzinfo=tzinfo
848 848 ))
849 849
850 850
851 851 def _shorten_commit_id(commit_id, commit_len=None):
852 852 if commit_len is None:
853 853 request = get_current_request()
854 854 commit_len = request.call_context.visual.show_sha_length
855 855 return commit_id[:commit_len]
856 856
857 857
858 858 def show_id(commit, show_idx=None, commit_len=None):
859 859 """
860 860 Configurable function that shows ID
861 861 by default it's r123:fffeeefffeee
862 862
863 863 :param commit: commit instance
864 864 """
865 865 if show_idx is None:
866 866 request = get_current_request()
867 867 show_idx = request.call_context.visual.show_revision_number
868 868
869 869 raw_id = _shorten_commit_id(commit.raw_id, commit_len=commit_len)
870 870 if show_idx:
871 871 return 'r%s:%s' % (commit.idx, raw_id)
872 872 else:
873 873 return '%s' % (raw_id, )
874 874
875 875
876 876 def format_date(date):
877 877 """
878 878 use a standardized formatting for dates used in RhodeCode
879 879
880 880 :param date: date/datetime object
881 881 :return: formatted date
882 882 """
883 883
884 884 if date:
885 885 _fmt = "%a, %d %b %Y %H:%M:%S"
886 886 return safe_str(date.strftime(_fmt))
887 887
888 888 return ""
889 889
890 890
891 891 class _RepoChecker(object):
892 892
893 893 def __init__(self, backend_alias):
894 894 self._backend_alias = backend_alias
895 895
896 896 def __call__(self, repository):
897 897 if hasattr(repository, 'alias'):
898 898 _type = repository.alias
899 899 elif hasattr(repository, 'repo_type'):
900 900 _type = repository.repo_type
901 901 else:
902 902 _type = repository
903 903 return _type == self._backend_alias
904 904
905 905
906 906 is_git = _RepoChecker('git')
907 907 is_hg = _RepoChecker('hg')
908 908 is_svn = _RepoChecker('svn')
909 909
910 910
911 911 def get_repo_type_by_name(repo_name):
912 912 repo = Repository.get_by_repo_name(repo_name)
913 913 if repo:
914 914 return repo.repo_type
915 915
916 916
917 917 def is_svn_without_proxy(repository):
918 918 if is_svn(repository):
919 919 from rhodecode.model.settings import VcsSettingsModel
920 920 conf = VcsSettingsModel().get_ui_settings_as_config_obj()
921 921 return not str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
922 922 return False
923 923
924 924
925 925 def discover_user(author):
926 926 """
927 927 Tries to discover RhodeCode User based on the author string. Author string
928 928 is typically `FirstName LastName <email@address.com>`
929 929 """
930 930
931 931 # if author is already an instance use it for extraction
932 932 if isinstance(author, User):
933 933 return author
934 934
935 935 # Valid email in the attribute passed, see if they're in the system
936 936 _email = author_email(author)
937 937 if _email != '':
938 938 user = User.get_by_email(_email, case_insensitive=True, cache=True)
939 939 if user is not None:
940 940 return user
941 941
942 942 # Maybe it's a username, we try to extract it and fetch by username ?
943 943 _author = author_name(author)
944 944 user = User.get_by_username(_author, case_insensitive=True, cache=True)
945 945 if user is not None:
946 946 return user
947 947
948 948 return None
949 949
950 950
951 951 def email_or_none(author):
952 952 # extract email from the commit string
953 953 _email = author_email(author)
954 954
955 955 # If we have an email, use it, otherwise
956 956 # see if it contains a username we can get an email from
957 957 if _email != '':
958 958 return _email
959 959 else:
960 960 user = User.get_by_username(
961 961 author_name(author), case_insensitive=True, cache=True)
962 962
963 963 if user is not None:
964 964 return user.email
965 965
966 966 # No valid email, not a valid user in the system, none!
967 967 return None
968 968
969 969
970 970 def link_to_user(author, length=0, **kwargs):
971 971 user = discover_user(author)
972 972 # user can be None, but if we have it already it means we can re-use it
973 973 # in the person() function, so we save 1 intensive-query
974 974 if user:
975 975 author = user
976 976
977 977 display_person = person(author, 'username_or_name_or_email')
978 978 if length:
979 979 display_person = shorter(display_person, length)
980 980
981 981 if user and user.username != user.DEFAULT_USER:
982 982 return link_to(
983 983 escape(display_person),
984 984 route_path('user_profile', username=user.username),
985 985 **kwargs)
986 986 else:
987 987 return escape(display_person)
988 988
989 989
990 990 def link_to_group(users_group_name, **kwargs):
991 991 return link_to(
992 992 escape(users_group_name),
993 993 route_path('user_group_profile', user_group_name=users_group_name),
994 994 **kwargs)
995 995
996 996
997 997 def person(author, show_attr="username_and_name"):
998 998 user = discover_user(author)
999 999 if user:
1000 1000 return getattr(user, show_attr)
1001 1001 else:
1002 1002 _author = author_name(author)
1003 1003 _email = email(author)
1004 1004 return _author or _email
1005 1005
1006 1006
1007 1007 def author_string(email):
1008 1008 if email:
1009 1009 user = User.get_by_email(email, case_insensitive=True, cache=True)
1010 1010 if user:
1011 1011 if user.first_name or user.last_name:
1012 1012 return '%s %s &lt;%s&gt;' % (
1013 1013 user.first_name, user.last_name, email)
1014 1014 else:
1015 1015 return email
1016 1016 else:
1017 1017 return email
1018 1018 else:
1019 1019 return None
1020 1020
1021 1021
1022 1022 def person_by_id(id_, show_attr="username_and_name"):
1023 1023 # attr to return from fetched user
1024 1024 def person_getter(usr):
1025 1025 return getattr(usr, show_attr)
1026 1026
1027 1027 #maybe it's an ID ?
1028 1028 if str(id_).isdigit() or isinstance(id_, int):
1029 1029 id_ = int(id_)
1030 1030 user = User.get(id_)
1031 1031 if user is not None:
1032 1032 return person_getter(user)
1033 1033 return id_
1034 1034
1035 1035
1036 1036 def gravatar_with_user(request, author, show_disabled=False, tooltip=False):
1037 1037 _render = request.get_partial_renderer('rhodecode:templates/base/base.mako')
1038 1038 return _render('gravatar_with_user', author, show_disabled=show_disabled, tooltip=tooltip)
1039 1039
1040 1040
1041 1041 tags_patterns = OrderedDict(
1042 1042 (
1043 1043 (
1044 1044 "lang",
1045 1045 (
1046 1046 re.compile(r"\[(lang|language)\ \=\&gt;\ *([a-zA-Z\-\/\#\+\.]*)\]"),
1047 1047 '<div class="metatag" tag="lang">\\2</div>',
1048 1048 ),
1049 1049 ),
1050 1050 (
1051 1051 "see",
1052 1052 (
1053 1053 re.compile(r"\[see\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]"),
1054 1054 '<div class="metatag" tag="see">see: \\1 </div>',
1055 1055 ),
1056 1056 ),
1057 1057 (
1058 1058 "url",
1059 1059 (
1060 1060 re.compile(
1061 1061 r"\[url\ \=\&gt;\ \[([a-zA-Z0-9\ \.\-\_]+)\]\((http://|https://|/)(.*?)\)\]"
1062 1062 ),
1063 1063 '<div class="metatag" tag="url"> <a href="\\2\\3">\\1</a> </div>',
1064 1064 ),
1065 1065 ),
1066 1066 (
1067 1067 "license",
1068 1068 (
1069 1069 re.compile(
1070 1070 r"\[license\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]"
1071 1071 ),
1072 r'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>',
1072 # don't make it a raw string here...
1073 '<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>',
1073 1074 ),
1074 1075 ),
1075 1076 (
1076 1077 "ref",
1077 1078 (
1078 1079 re.compile(
1079 1080 r"\[(requires|recommends|conflicts|base)\ \=\&gt;\ *([a-zA-Z0-9\-\/]*)\]"
1080 1081 ),
1081 1082 '<div class="metatag" tag="ref \\1">\\1: <a href="/\\2">\\2</a></div>',
1082 1083 ),
1083 1084 ),
1084 1085 (
1085 1086 "state",
1086 1087 (
1087 1088 re.compile(r"\[(stable|featured|stale|dead|dev|deprecated)\]"),
1088 1089 '<div class="metatag" tag="state \\1">\\1</div>',
1089 1090 ),
1090 1091 ),
1091 1092 # label in grey
1092 1093 (
1093 1094 "label",
1094 1095 (re.compile(r"\[([a-z]+)\]"), '<div class="metatag" tag="label">\\1</div>'),
1095 1096 ),
1096 1097 # generic catch all in grey
1097 1098 (
1098 1099 "generic",
1099 1100 (
1100 1101 re.compile(r"\[([a-zA-Z0-9\.\-\_]+)\]"),
1101 1102 '<div class="metatag" tag="generic">\\1</div>',
1102 1103 ),
1103 1104 ),
1104 1105 )
1105 1106 )
1106 1107
1107 1108
1108 1109 def extract_metatags(value):
1109 1110 """
1110 1111 Extract supported meta-tags from given text value
1111 1112 """
1112 1113 tags = []
1113 1114 if not value:
1114 1115 return tags, ''
1115 1116
1116 1117 for key, val in list(tags_patterns.items()):
1117 1118 pat, replace_html = val
1118 1119 tags.extend([(key, x.group()) for x in pat.finditer(value)])
1119 1120 value = pat.sub('', value)
1120 1121
1121 1122 return tags, value
1122 1123
1123 1124
1124 1125 def style_metatag(tag_type, value):
1125 1126 """
1126 1127 converts tags from value into html equivalent
1127 1128 """
1128 1129 if not value:
1129 1130 return ''
1130 1131
1131 1132 html_value = value
1132 1133 tag_data = tags_patterns.get(tag_type)
1133 1134 if tag_data:
1134 1135 pat, replace_html = tag_data
1135 1136 # convert to plain `str` instead of a markup tag to be used in
1136 1137 # regex expressions. safe_str doesn't work here
1137 1138 html_value = pat.sub(replace_html, value)
1138 1139
1139 1140 return html_value
1140 1141
1141 1142
1142 1143 def bool2icon(value, show_at_false=True):
1143 1144 """
1144 1145 Returns boolean value of a given value, represented as html element with
1145 1146 classes that will represent icons
1146 1147
1147 1148 :param value: given value to convert to html node
1148 1149 """
1149 1150
1150 1151 if value: # does bool conversion
1151 1152 return HTML.tag('i', class_="icon-true", title='True')
1152 1153 else: # not true as bool
1153 1154 if show_at_false:
1154 1155 return HTML.tag('i', class_="icon-false", title='False')
1155 1156 return HTML.tag('i')
1156 1157
1157 1158
1158 1159 def b64(inp):
1159 1160 return base64.b64encode(safe_bytes(inp))
1160 1161
1161 1162 #==============================================================================
1162 1163 # PERMS
1163 1164 #==============================================================================
1164 1165 from rhodecode.lib.auth import (
1165 1166 HasPermissionAny, HasPermissionAll,
1166 1167 HasRepoPermissionAny, HasRepoPermissionAll, HasRepoGroupPermissionAll,
1167 1168 HasRepoGroupPermissionAny, HasRepoPermissionAnyApi, get_csrf_token,
1168 1169 csrf_token_key, AuthUser)
1169 1170
1170 1171
1171 1172 #==============================================================================
1172 1173 # GRAVATAR URL
1173 1174 #==============================================================================
1174 1175 class InitialsGravatar(object):
1175 1176 def __init__(self, email_address, first_name, last_name, size=30,
1176 1177 background=None, text_color='#fff'):
1177 1178 self.size = size
1178 1179 self.first_name = first_name
1179 1180 self.last_name = last_name
1180 1181 self.email_address = email_address
1181 1182 self.background = background or self.str2color(email_address)
1182 1183 self.text_color = text_color
1183 1184
1184 1185 def get_color_bank(self):
1185 1186 """
1186 1187 returns a predefined list of colors that gravatars can use.
1187 1188 Those are randomized distinct colors that guarantee readability and
1188 1189 uniqueness.
1189 1190
1190 1191 generated with: http://phrogz.net/css/distinct-colors.html
1191 1192 """
1192 1193 return [
1193 1194 '#bf3030', '#a67f53', '#00ff00', '#5989b3', '#392040', '#d90000',
1194 1195 '#402910', '#204020', '#79baf2', '#a700b3', '#bf6060', '#7f5320',
1195 1196 '#008000', '#003059', '#ee00ff', '#ff0000', '#8c4b00', '#007300',
1196 1197 '#005fb3', '#de73e6', '#ff4040', '#ffaa00', '#3df255', '#203140',
1197 1198 '#47004d', '#591616', '#664400', '#59b365', '#0d2133', '#83008c',
1198 1199 '#592d2d', '#bf9f60', '#73e682', '#1d3f73', '#73006b', '#402020',
1199 1200 '#b2862d', '#397341', '#597db3', '#e600d6', '#a60000', '#736039',
1200 1201 '#00b318', '#79aaf2', '#330d30', '#ff8080', '#403010', '#16591f',
1201 1202 '#002459', '#8c4688', '#e50000', '#ffbf40', '#00732e', '#102340',
1202 1203 '#bf60ac', '#8c4646', '#cc8800', '#00a642', '#1d3473', '#b32d98',
1203 1204 '#660e00', '#ffd580', '#80ffb2', '#7391e6', '#733967', '#d97b6c',
1204 1205 '#8c5e00', '#59b389', '#3967e6', '#590047', '#73281d', '#665200',
1205 1206 '#00e67a', '#2d50b3', '#8c2377', '#734139', '#b2982d', '#16593a',
1206 1207 '#001859', '#ff00aa', '#a65e53', '#ffcc00', '#0d3321', '#2d3959',
1207 1208 '#731d56', '#401610', '#4c3d00', '#468c6c', '#002ca6', '#d936a3',
1208 1209 '#d94c36', '#403920', '#36d9a3', '#0d1733', '#592d4a', '#993626',
1209 1210 '#cca300', '#00734d', '#46598c', '#8c005e', '#7f1100', '#8c7000',
1210 1211 '#00a66f', '#7382e6', '#b32d74', '#d9896c', '#ffe680', '#1d7362',
1211 1212 '#364cd9', '#73003d', '#d93a00', '#998a4d', '#59b3a1', '#5965b3',
1212 1213 '#e5007a', '#73341d', '#665f00', '#00b38f', '#0018b3', '#59163a',
1213 1214 '#b2502d', '#bfb960', '#00ffcc', '#23318c', '#a6537f', '#734939',
1214 1215 '#b2a700', '#104036', '#3d3df2', '#402031', '#e56739', '#736f39',
1215 1216 '#79f2ea', '#000059', '#401029', '#4c1400', '#ffee00', '#005953',
1216 1217 '#101040', '#990052', '#402820', '#403d10', '#00ffee', '#0000d9',
1217 1218 '#ff80c4', '#a66953', '#eeff00', '#00ccbe', '#8080ff', '#e673a1',
1218 1219 '#a62c00', '#474d00', '#1a3331', '#46468c', '#733950', '#662900',
1219 1220 '#858c23', '#238c85', '#0f0073', '#b20047', '#d9986c', '#becc00',
1220 1221 '#396f73', '#281d73', '#ff0066', '#ff6600', '#dee673', '#59adb3',
1221 1222 '#6559b3', '#590024', '#b2622d', '#98b32d', '#36ced9', '#332d59',
1222 1223 '#40001a', '#733f1d', '#526600', '#005359', '#242040', '#bf6079',
1223 1224 '#735039', '#cef23d', '#007780', '#5630bf', '#66001b', '#b24700',
1224 1225 '#acbf60', '#1d6273', '#25008c', '#731d34', '#a67453', '#50592d',
1225 1226 '#00ccff', '#6600ff', '#ff0044', '#4c1f00', '#8a994d', '#79daf2',
1226 1227 '#a173e6', '#d93662', '#402310', '#aaff00', '#2d98b3', '#8c40ff',
1227 1228 '#592d39', '#ff8c40', '#354020', '#103640', '#1a0040', '#331a20',
1228 1229 '#331400', '#334d00', '#1d5673', '#583973', '#7f0022', '#4c3626',
1229 1230 '#88cc00', '#36a3d9', '#3d0073', '#d9364c', '#33241a', '#698c23',
1230 1231 '#5995b3', '#300059', '#e57382', '#7f3300', '#366600', '#00aaff',
1231 1232 '#3a1659', '#733941', '#663600', '#74b32d', '#003c59', '#7f53a6',
1232 1233 '#73000f', '#ff8800', '#baf279', '#79caf2', '#291040', '#a6293a',
1233 1234 '#b2742d', '#587339', '#0077b3', '#632699', '#400009', '#d9a66c',
1234 1235 '#294010', '#2d4a59', '#aa00ff', '#4c131b', '#b25f00', '#5ce600',
1235 1236 '#267399', '#a336d9', '#990014', '#664e33', '#86bf60', '#0088ff',
1236 1237 '#7700b3', '#593a16', '#073300', '#1d4b73', '#ac60bf', '#e59539',
1237 1238 '#4f8c46', '#368dd9', '#5c0073'
1238 1239 ]
1239 1240
1240 1241 def rgb_to_hex_color(self, rgb_tuple):
1241 1242 """
1242 1243 Converts an rgb_tuple passed to an hex color.
1243 1244
1244 1245 :param rgb_tuple: tuple with 3 ints represents rgb color space
1245 1246 """
1246 1247 return '#' + ("".join(map(chr, rgb_tuple)).encode('hex'))
1247 1248
1248 1249 def email_to_int_list(self, email_str):
1249 1250 """
1250 1251 Get every byte of the hex digest value of email and turn it to integer.
1251 1252 It's going to be always between 0-255
1252 1253 """
1253 1254 digest = md5_safe(email_str.lower())
1254 1255 return [int(digest[i * 2:i * 2 + 2], 16) for i in range(16)]
1255 1256
1256 1257 def pick_color_bank_index(self, email_str, color_bank):
1257 1258 return self.email_to_int_list(email_str)[0] % len(color_bank)
1258 1259
1259 1260 def str2color(self, email_str):
1260 1261 """
1261 1262 Tries to map in a stable algorithm an email to color
1262 1263
1263 1264 :param email_str:
1264 1265 """
1265 1266 color_bank = self.get_color_bank()
1266 1267 # pick position (module it's length so we always find it in the
1267 1268 # bank even if it's smaller than 256 values
1268 1269 pos = self.pick_color_bank_index(email_str, color_bank)
1269 1270 return color_bank[pos]
1270 1271
1271 1272 def normalize_email(self, email_address):
1272 1273 # default host used to fill in the fake/missing email
1273 1274 default_host = 'localhost'
1274 1275
1275 1276 if not email_address:
1276 1277 email_address = f'{User.DEFAULT_USER}@{default_host}'
1277 1278
1278 1279 email_address = safe_str(email_address)
1279 1280
1280 1281 if '@' not in email_address:
1281 1282 email_address = f'{email_address}@{default_host}'
1282 1283
1283 1284 if email_address.endswith('@'):
1284 1285 email_address = f'{email_address}{default_host}'
1285 1286
1286 1287 email_address = convert_special_chars(email_address)
1287 1288
1288 1289 return email_address
1289 1290
1290 1291 def get_initials(self):
1291 1292 """
1292 1293 Returns 2 letter initials calculated based on the input.
1293 1294 The algorithm picks first given email address, and takes first letter
1294 1295 of part before @, and then the first letter of server name. In case
1295 1296 the part before @ is in a format of `somestring.somestring2` it replaces
1296 1297 the server letter with first letter of somestring2
1297 1298
1298 1299 In case function was initialized with both first and lastname, this
1299 1300 overrides the extraction from email by first letter of the first and
1300 1301 last name. We add special logic to that functionality, In case Full name
1301 1302 is compound, like Guido Von Rossum, we use last part of the last name
1302 1303 (Von Rossum) picking `R`.
1303 1304
1304 1305 Function also normalizes the non-ascii characters to they ascii
1305 1306 representation, eg Δ„ => A
1306 1307 """
1307 1308 # replace non-ascii to ascii
1308 1309 first_name = convert_special_chars(self.first_name)
1309 1310 last_name = convert_special_chars(self.last_name)
1310 1311 # multi word last names, Guido Von Rossum, we take the last part only
1311 1312 last_name = last_name.split(' ', 1)[-1]
1312 1313
1313 1314 # do NFKD encoding, and also make sure email has proper format
1314 1315 email_address = self.normalize_email(self.email_address)
1315 1316
1316 1317 # first push the email initials
1317 1318 prefix, server = email_address.split('@', 1)
1318 1319
1319 1320 # check if prefix is maybe a 'first_name.last_name' syntax
1320 1321 _dot_split = prefix.rsplit('.', 1)
1321 1322 if len(_dot_split) == 2 and _dot_split[1]:
1322 1323 initials = [_dot_split[0][0], _dot_split[1][0]]
1323 1324 else:
1324 1325 initials = [prefix[0], server[0]]
1325 1326
1326 1327 # get first letter of first and last names to create initials
1327 1328 fn_letter = (first_name or " ")[0].strip()
1328 1329 ln_letter = (last_name or " ")[0].strip()
1329 1330
1330 1331 if fn_letter:
1331 1332 initials[0] = fn_letter
1332 1333
1333 1334 if ln_letter:
1334 1335 initials[1] = ln_letter
1335 1336
1336 1337 return ''.join(initials).upper()
1337 1338
1338 1339 def get_img_data_by_type(self, font_family, img_type):
1339 1340 default_user = """
1340 1341 <svg xmlns="http://www.w3.org/2000/svg"
1341 1342 version="1.1" x="0px" y="0px" width="{size}" height="{size}"
1342 1343 viewBox="-15 -10 439.165 429.164"
1343 1344
1344 1345 xml:space="preserve"
1345 1346 font-family="{font_family}"
1346 1347 style="background:{background};" >
1347 1348
1348 1349 <path d="M204.583,216.671c50.664,0,91.74-48.075,
1349 1350 91.74-107.378c0-82.237-41.074-107.377-91.74-107.377
1350 1351 c-50.668,0-91.74,25.14-91.74,107.377C112.844,
1351 1352 168.596,153.916,216.671,
1352 1353 204.583,216.671z" fill="{text_color}"/>
1353 1354 <path d="M407.164,374.717L360.88,
1354 1355 270.454c-2.117-4.771-5.836-8.728-10.465-11.138l-71.83-37.392
1355 1356 c-1.584-0.823-3.502-0.663-4.926,0.415c-20.316,
1356 1357 15.366-44.203,23.488-69.076,23.488c-24.877,
1357 1358 0-48.762-8.122-69.078-23.488
1358 1359 c-1.428-1.078-3.346-1.238-4.93-0.415L58.75,
1359 1360 259.316c-4.631,2.41-8.346,6.365-10.465,11.138L2.001,374.717
1360 1361 c-3.191,7.188-2.537,15.412,1.75,22.005c4.285,
1361 1362 6.592,11.537,10.526,19.4,10.526h362.861c7.863,0,15.117-3.936,
1362 1363 19.402-10.527 C409.699,390.129,
1363 1364 410.355,381.902,407.164,374.717z" fill="{text_color}"/>
1364 1365 </svg>""".format(
1365 1366 size=self.size,
1366 1367 background='#979797', # @grey4
1367 1368 text_color=self.text_color,
1368 1369 font_family=font_family)
1369 1370
1370 1371 return {
1371 1372 "default_user": default_user
1372 1373 }[img_type]
1373 1374
1374 1375 def get_img_data(self, svg_type=None):
1375 1376 """
1376 1377 generates the svg metadata for image
1377 1378 """
1378 1379 fonts = [
1379 1380 '-apple-system',
1380 1381 'BlinkMacSystemFont',
1381 1382 'Segoe UI',
1382 1383 'Roboto',
1383 1384 'Oxygen-Sans',
1384 1385 'Ubuntu',
1385 1386 'Cantarell',
1386 1387 'Helvetica Neue',
1387 1388 'sans-serif'
1388 1389 ]
1389 1390 font_family = ','.join(fonts)
1390 1391 if svg_type:
1391 1392 return self.get_img_data_by_type(font_family, svg_type)
1392 1393
1393 1394 initials = self.get_initials()
1394 1395 img_data = """
1395 1396 <svg xmlns="http://www.w3.org/2000/svg" pointer-events="none"
1396 1397 width="{size}" height="{size}"
1397 1398 style="width: 100%; height: 100%; background-color: {background}"
1398 1399 viewBox="0 0 {size} {size}">
1399 1400 <text text-anchor="middle" y="50%" x="50%" dy="0.35em"
1400 1401 pointer-events="auto" fill="{text_color}"
1401 1402 font-family="{font_family}"
1402 1403 style="font-weight: 400; font-size: {f_size}px;">{text}
1403 1404 </text>
1404 1405 </svg>""".format(
1405 1406 size=self.size,
1406 1407 f_size=self.size/2.05, # scale the text inside the box nicely
1407 1408 background=self.background,
1408 1409 text_color=self.text_color,
1409 1410 text=initials.upper(),
1410 1411 font_family=font_family)
1411 1412
1412 1413 return img_data
1413 1414
1414 1415 def generate_svg(self, svg_type=None):
1415 1416 img_data = base64_to_str(self.get_img_data(svg_type))
1416 1417 return "data:image/svg+xml;base64,{}".format(img_data)
1417 1418
1418 1419
1419 1420 def initials_gravatar(request, email_address, first_name, last_name, size=30, store_on_disk=False):
1420 1421
1421 1422 svg_type = None
1422 1423 if email_address == User.DEFAULT_USER_EMAIL:
1423 1424 svg_type = 'default_user'
1424 1425
1425 1426 klass = InitialsGravatar(email_address, first_name, last_name, size)
1426 1427
1427 1428 if store_on_disk:
1428 1429 from rhodecode.apps.file_store import utils as store_utils
1429 1430 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, \
1430 1431 FileOverSizeException
1431 1432 from rhodecode.model.db import Session
1432 1433
1433 1434 image_key = md5_safe(email_address.lower()
1434 1435 + first_name.lower() + last_name.lower())
1435 1436
1436 1437 storage = store_utils.get_file_storage(request.registry.settings)
1437 1438 filename = '{}.svg'.format(image_key)
1438 1439 subdir = 'gravatars'
1439 1440 # since final name has a counter, we apply the 0
1440 1441 uid = storage.apply_counter(0, store_utils.uid_filename(filename, randomized=False))
1441 1442 store_uid = os.path.join(subdir, uid)
1442 1443
1443 1444 db_entry = FileStore.get_by_store_uid(store_uid)
1444 1445 if db_entry:
1445 1446 return request.route_path('download_file', fid=store_uid)
1446 1447
1447 1448 img_data = klass.get_img_data(svg_type=svg_type)
1448 1449 img_file = store_utils.bytes_to_file_obj(img_data)
1449 1450
1450 1451 try:
1451 1452 store_uid, metadata = storage.save_file(
1452 1453 img_file, filename, directory=subdir,
1453 1454 extensions=['.svg'], randomized_name=False)
1454 1455 except (FileNotAllowedException, FileOverSizeException):
1455 1456 raise
1456 1457
1457 1458 try:
1458 1459 entry = FileStore.create(
1459 1460 file_uid=store_uid, filename=metadata["filename"],
1460 1461 file_hash=metadata["sha256"], file_size=metadata["size"],
1461 1462 file_display_name=filename,
1462 1463 file_description=f'user gravatar `{safe_str(filename)}`',
1463 1464 hidden=True, check_acl=False, user_id=1
1464 1465 )
1465 1466 Session().add(entry)
1466 1467 Session().commit()
1467 1468 log.debug('Stored upload in DB as %s', entry)
1468 1469 except Exception:
1469 1470 raise
1470 1471
1471 1472 return request.route_path('download_file', fid=store_uid)
1472 1473
1473 1474 else:
1474 1475 return klass.generate_svg(svg_type=svg_type)
1475 1476
1476 1477
1477 1478 def gravatar_external(request, gravatar_url_tmpl, email_address, size=30):
1478 1479 return safe_str(gravatar_url_tmpl)\
1479 1480 .replace('{email}', email_address) \
1480 1481 .replace('{md5email}', md5_safe(email_address.lower())) \
1481 1482 .replace('{netloc}', request.host) \
1482 1483 .replace('{scheme}', request.scheme) \
1483 1484 .replace('{size}', safe_str(size))
1484 1485
1485 1486
1486 1487 def gravatar_url(email_address, size=30, request=None):
1487 1488 request = request or get_current_request()
1488 1489 _use_gravatar = request.call_context.visual.use_gravatar
1489 1490
1490 1491 email_address = email_address or User.DEFAULT_USER_EMAIL
1491 1492 if isinstance(email_address, str):
1492 1493 # hashlib crashes on unicode items
1493 1494 email_address = safe_str(email_address)
1494 1495
1495 1496 # empty email or default user
1496 1497 if not email_address or email_address == User.DEFAULT_USER_EMAIL:
1497 1498 return initials_gravatar(request, User.DEFAULT_USER_EMAIL, '', '', size=size)
1498 1499
1499 1500 if _use_gravatar:
1500 1501 gravatar_url_tmpl = request.call_context.visual.gravatar_url \
1501 1502 or User.DEFAULT_GRAVATAR_URL
1502 1503 return gravatar_external(request, gravatar_url_tmpl, email_address, size=size)
1503 1504
1504 1505 else:
1505 1506 return initials_gravatar(request, email_address, '', '', size=size)
1506 1507
1507 1508
1508 1509 def breadcrumb_repo_link(repo):
1509 1510 """
1510 1511 Makes a breadcrumbs path link to repo
1511 1512
1512 1513 ex::
1513 1514 group >> subgroup >> repo
1514 1515
1515 1516 :param repo: a Repository instance
1516 1517 """
1517 1518
1518 1519 path = [
1519 1520 link_to(group.name, route_path('repo_group_home', repo_group_name=group.group_name),
1520 1521 title='last change:{}'.format(format_date(group.last_commit_change)))
1521 1522 for group in repo.groups_with_parents
1522 1523 ] + [
1523 1524 link_to(repo.just_name, route_path('repo_summary', repo_name=repo.repo_name),
1524 1525 title='last change:{}'.format(format_date(repo.last_commit_change)))
1525 1526 ]
1526 1527
1527 1528 return literal(' &raquo; '.join(path))
1528 1529
1529 1530
1530 1531 def breadcrumb_repo_group_link(repo_group):
1531 1532 """
1532 1533 Makes a breadcrumbs path link to repo
1533 1534
1534 1535 ex::
1535 1536 group >> subgroup
1536 1537
1537 1538 :param repo_group: a Repository Group instance
1538 1539 """
1539 1540
1540 1541 path = [
1541 1542 link_to(group.name,
1542 1543 route_path('repo_group_home', repo_group_name=group.group_name),
1543 1544 title='last change:{}'.format(format_date(group.last_commit_change)))
1544 1545 for group in repo_group.parents
1545 1546 ] + [
1546 1547 link_to(repo_group.name,
1547 1548 route_path('repo_group_home', repo_group_name=repo_group.group_name),
1548 1549 title='last change:{}'.format(format_date(repo_group.last_commit_change)))
1549 1550 ]
1550 1551
1551 1552 return literal(' &raquo; '.join(path))
1552 1553
1553 1554
1554 1555 def format_byte_size_binary(file_size):
1555 1556 """
1556 1557 Formats file/folder sizes to standard.
1557 1558 """
1558 1559 if file_size is None:
1559 1560 file_size = 0
1560 1561
1561 1562 formatted_size = format_byte_size(file_size, binary=True)
1562 1563 return formatted_size
1563 1564
1564 1565
1565 1566 def urlify_text(text_, safe=True, **href_attrs):
1566 1567 """
1567 1568 Extract urls from text and make html links out of them
1568 1569 """
1569 1570
1570 1571 url_pat = re.compile(r'''(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@#.&+]'''
1571 1572 r'''|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)''')
1572 1573
1573 1574 def url_func(match_obj):
1574 1575 url_full = match_obj.groups()[0]
1575 1576 a_options = dict(href_attrs)
1576 1577 a_options['href'] = url_full
1577 1578 a_text = url_full
1578 1579 return HTML.tag("a", a_text, **a_options)
1579 1580
1580 1581 _new_text = url_pat.sub(url_func, text_)
1581 1582
1582 1583 if safe:
1583 1584 return literal(_new_text)
1584 1585 return _new_text
1585 1586
1586 1587
1587 1588 def urlify_commits(text_, repo_name):
1588 1589 """
1589 1590 Extract commit ids from text and make link from them
1590 1591
1591 1592 :param text_:
1592 1593 :param repo_name: repo name to build the URL with
1593 1594 """
1594 1595
1595 1596 url_pat = re.compile(r'(^|\s)([0-9a-fA-F]{12,40})($|\s)')
1596 1597
1597 1598 def url_func(match_obj):
1598 1599 commit_id = match_obj.groups()[1]
1599 1600 pref = match_obj.groups()[0]
1600 1601 suf = match_obj.groups()[2]
1601 1602
1602 1603 tmpl = (
1603 1604 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-alt="%(hovercard_alt)s" data-hovercard-url="%(hovercard_url)s">'
1604 1605 '%(commit_id)s</a>%(suf)s'
1605 1606 )
1606 1607 return tmpl % {
1607 1608 'pref': pref,
1608 1609 'cls': 'revision-link',
1609 1610 'url': route_url(
1610 1611 'repo_commit', repo_name=repo_name, commit_id=commit_id),
1611 1612 'commit_id': commit_id,
1612 1613 'suf': suf,
1613 1614 'hovercard_alt': 'Commit: {}'.format(commit_id),
1614 1615 'hovercard_url': route_url(
1615 1616 'hovercard_repo_commit', repo_name=repo_name, commit_id=commit_id)
1616 1617 }
1617 1618
1618 1619 new_text = url_pat.sub(url_func, text_)
1619 1620
1620 1621 return new_text
1621 1622
1622 1623
1623 1624 def _process_url_func(match_obj, repo_name, uid, entry,
1624 1625 return_raw_data=False, link_format='html'):
1625 1626 pref = ''
1626 1627 if match_obj.group().startswith(' '):
1627 1628 pref = ' '
1628 1629
1629 1630 issue_id = ''.join(match_obj.groups())
1630 1631
1631 1632 if link_format == 'html':
1632 1633 tmpl = (
1633 1634 '%(pref)s<a class="tooltip %(cls)s" href="%(url)s" title="%(title)s">'
1634 1635 '%(issue-prefix)s%(id-repr)s'
1635 1636 '</a>')
1636 1637 elif link_format == 'html+hovercard':
1637 1638 tmpl = (
1638 1639 '%(pref)s<a class="tooltip-hovercard %(cls)s" href="%(url)s" data-hovercard-url="%(hovercard_url)s">'
1639 1640 '%(issue-prefix)s%(id-repr)s'
1640 1641 '</a>')
1641 1642 elif link_format in ['rst', 'rst+hovercard']:
1642 1643 tmpl = '`%(issue-prefix)s%(id-repr)s <%(url)s>`_'
1643 1644 elif link_format in ['markdown', 'markdown+hovercard']:
1644 1645 tmpl = '[%(pref)s%(issue-prefix)s%(id-repr)s](%(url)s)'
1645 1646 else:
1646 1647 raise ValueError('Bad link_format:{}'.format(link_format))
1647 1648
1648 1649 (repo_name_cleaned,
1649 1650 parent_group_name) = RepoGroupModel()._get_group_name_and_parent(repo_name)
1650 1651
1651 1652 # variables replacement
1652 1653 named_vars = {
1653 1654 'id': issue_id,
1654 1655 'repo': repo_name,
1655 1656 'repo_name': repo_name_cleaned,
1656 1657 'group_name': parent_group_name,
1657 1658 # set dummy keys so we always have them
1658 1659 'hostname': '',
1659 1660 'netloc': '',
1660 1661 'scheme': ''
1661 1662 }
1662 1663
1663 1664 request = get_current_request()
1664 1665 if request:
1665 1666 # exposes, hostname, netloc, scheme
1666 1667 host_data = get_host_info(request)
1667 1668 named_vars.update(host_data)
1668 1669
1669 1670 # named regex variables
1670 1671 named_vars.update(match_obj.groupdict())
1671 1672 _url = string.Template(entry['url']).safe_substitute(**named_vars)
1672 1673 desc = string.Template(escape(entry['desc'])).safe_substitute(**named_vars)
1673 1674 hovercard_url = string.Template(entry.get('hovercard_url', '')).safe_substitute(**named_vars)
1674 1675
1675 1676 def quote_cleaner(input_str):
1676 1677 """Remove quotes as it's HTML"""
1677 1678 return input_str.replace('"', '')
1678 1679
1679 1680 data = {
1680 1681 'pref': pref,
1681 1682 'cls': quote_cleaner('issue-tracker-link'),
1682 1683 'url': quote_cleaner(_url),
1683 1684 'id-repr': issue_id,
1684 1685 'issue-prefix': entry['pref'],
1685 1686 'serv': entry['url'],
1686 1687 'title': sanitize_html(desc, strip=True),
1687 1688 'hovercard_url': hovercard_url
1688 1689 }
1689 1690
1690 1691 if return_raw_data:
1691 1692 return {
1692 1693 'id': issue_id,
1693 1694 'url': _url
1694 1695 }
1695 1696 return tmpl % data
1696 1697
1697 1698
1698 1699 def get_active_pattern_entries(repo_name):
1699 1700 repo = None
1700 1701 if repo_name:
1701 1702 # Retrieving repo_name to avoid invalid repo_name to explode on
1702 1703 # IssueTrackerSettingsModel but still passing invalid name further down
1703 1704 repo = Repository.get_by_repo_name(repo_name, cache=True)
1704 1705
1705 1706 settings_model = IssueTrackerSettingsModel(repo=repo)
1706 1707 active_entries = settings_model.get_settings(cache=True)
1707 1708 return active_entries
1708 1709
1709 1710
1710 1711 pr_pattern_re = regex.compile(r'(?:(?:^!)|(?: !))(\d+)')
1711 1712
1712 1713 allowed_link_formats = [
1713 1714 'html', 'rst', 'markdown', 'html+hovercard', 'rst+hovercard', 'markdown+hovercard']
1714 1715
1715 1716 compile_cache = {
1716 1717
1717 1718 }
1718 1719
1719 1720
1720 1721 def process_patterns(text_string, repo_name, link_format='html', active_entries=None):
1721 1722
1722 1723 if link_format not in allowed_link_formats:
1723 1724 raise ValueError('Link format can be only one of:{} got {}'.format(
1724 1725 allowed_link_formats, link_format))
1725 1726 issues_data = []
1726 1727 errors = []
1727 1728 new_text = text_string
1728 1729
1729 1730 if active_entries is None:
1730 1731 log.debug('Fetch active issue tracker patterns for repo: %s', repo_name)
1731 1732 active_entries = get_active_pattern_entries(repo_name)
1732 1733
1733 1734 log.debug('Got %s pattern entries to process', len(active_entries))
1734 1735
1735 1736 for uid, entry in list(active_entries.items()):
1736 1737
1737 1738 if not (entry['pat'] and entry['url']):
1738 1739 log.debug('skipping due to missing data')
1739 1740 continue
1740 1741
1741 1742 log.debug('issue tracker entry: uid: `%s` PAT:%s URL:%s PREFIX:%s',
1742 1743 uid, entry['pat'], entry['url'], entry['pref'])
1743 1744
1744 1745 if entry.get('pat_compiled'):
1745 1746 pattern = entry['pat_compiled']
1746 1747 elif entry['pat'] in compile_cache:
1747 1748 pattern = compile_cache[entry['pat']]
1748 1749 else:
1749 1750 try:
1750 1751 pattern = regex.compile(r'%s' % entry['pat'])
1751 1752 except regex.error as e:
1752 1753 regex_err = ValueError('{}:{}'.format(entry['pat'], e))
1753 1754 log.exception('issue tracker pattern: `%s` failed to compile', regex_err)
1754 1755 errors.append(regex_err)
1755 1756 continue
1756 1757 compile_cache[entry['pat']] = pattern
1757 1758
1758 1759 data_func = partial(
1759 1760 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1760 1761 return_raw_data=True)
1761 1762
1762 1763 for match_obj in pattern.finditer(text_string):
1763 1764 issues_data.append(data_func(match_obj))
1764 1765
1765 1766 url_func = partial(
1766 1767 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1767 1768 link_format=link_format)
1768 1769
1769 1770 new_text = pattern.sub(url_func, new_text)
1770 1771 log.debug('processed prefix:uid `%s`', uid)
1771 1772
1772 1773 # finally use global replace, eg !123 -> pr-link, those will not catch
1773 1774 # if already similar pattern exists
1774 1775 server_url = '${scheme}://${netloc}'
1775 1776 pr_entry = {
1776 1777 'pref': '!',
1777 1778 'url': server_url + '/_admin/pull-requests/${id}',
1778 1779 'desc': 'Pull Request !${id}',
1779 1780 'hovercard_url': server_url + '/_hovercard/pull_request/${id}'
1780 1781 }
1781 1782 pr_url_func = partial(
1782 1783 _process_url_func, repo_name=repo_name, entry=pr_entry, uid=None,
1783 1784 link_format=link_format+'+hovercard')
1784 1785 new_text = pr_pattern_re.sub(pr_url_func, new_text)
1785 1786 log.debug('processed !pr pattern')
1786 1787
1787 1788 return new_text, issues_data, errors
1788 1789
1789 1790
1790 1791 def urlify_commit_message(commit_text, repository=None, active_pattern_entries=None,
1791 1792 issues_container_callback=None, error_container=None):
1792 1793 """
1793 1794 Parses given text message and makes proper links.
1794 1795 issues are linked to given issue-server, and rest is a commit link
1795 1796 """
1796 1797
1797 1798 def escaper(_text):
1798 1799 return _text.replace('<', '&lt;').replace('>', '&gt;')
1799 1800
1800 1801 new_text = escaper(commit_text)
1801 1802
1802 1803 # extract http/https links and make them real urls
1803 1804 new_text = urlify_text(new_text, safe=False)
1804 1805
1805 1806 # urlify commits - extract commit ids and make link out of them, if we have
1806 1807 # the scope of repository present.
1807 1808 if repository:
1808 1809 new_text = urlify_commits(new_text, repository)
1809 1810
1810 1811 # process issue tracker patterns
1811 1812 new_text, issues, errors = process_patterns(
1812 1813 new_text, repository or '', active_entries=active_pattern_entries)
1813 1814
1814 1815 if issues_container_callback is not None:
1815 1816 for issue in issues:
1816 1817 issues_container_callback(issue)
1817 1818
1818 1819 if error_container is not None:
1819 1820 error_container.extend(errors)
1820 1821
1821 1822 return literal(new_text)
1822 1823
1823 1824
1824 1825 def render_binary(repo_name, file_obj):
1825 1826 """
1826 1827 Choose how to render a binary file
1827 1828 """
1828 1829
1829 1830 # unicode
1830 1831 filename = file_obj.name
1831 1832
1832 1833 # images
1833 1834 for ext in ['*.png', '*.jpeg', '*.jpg', '*.ico', '*.gif']:
1834 1835 if fnmatch.fnmatch(filename, pat=ext):
1835 1836 src = route_path(
1836 1837 'repo_file_raw', repo_name=repo_name,
1837 1838 commit_id=file_obj.commit.raw_id,
1838 1839 f_path=file_obj.path)
1839 1840
1840 1841 return literal(
1841 1842 '<img class="rendered-binary" alt="rendered-image" src="{}">'.format(src))
1842 1843
1843 1844
1844 1845 def renderer_from_filename(filename, exclude=None):
1845 1846 """
1846 1847 choose a renderer based on filename, this works only for text based files
1847 1848 """
1848 1849
1849 1850 # ipython
1850 1851 for ext in ['*.ipynb']:
1851 1852 if fnmatch.fnmatch(filename, pat=ext):
1852 1853 return 'jupyter'
1853 1854
1854 1855 is_markup = MarkupRenderer.renderer_from_filename(filename, exclude=exclude)
1855 1856 if is_markup:
1856 1857 return is_markup
1857 1858 return None
1858 1859
1859 1860
1860 1861 def render(source, renderer='rst', mentions=False, relative_urls=None,
1861 1862 repo_name=None, active_pattern_entries=None, issues_container_callback=None):
1862 1863
1863 1864 def maybe_convert_relative_links(html_source):
1864 1865 if relative_urls:
1865 1866 return relative_links(html_source, relative_urls)
1866 1867 return html_source
1867 1868
1868 1869 if renderer == 'plain':
1869 1870 return literal(
1870 1871 MarkupRenderer.plain(source, leading_newline=False))
1871 1872
1872 1873 elif renderer == 'rst':
1873 1874 if repo_name:
1874 1875 # process patterns on comments if we pass in repo name
1875 1876 source, issues, errors = process_patterns(
1876 1877 source, repo_name, link_format='rst',
1877 1878 active_entries=active_pattern_entries)
1878 1879 if issues_container_callback is not None:
1879 1880 for issue in issues:
1880 1881 issues_container_callback(issue)
1881 1882
1882 1883 rendered_block = maybe_convert_relative_links(
1883 1884 MarkupRenderer.rst(source, mentions=mentions))
1884 1885
1885 1886 return literal(f'<div class="rst-block">{rendered_block}</div>')
1886 1887
1887 1888 elif renderer == 'markdown':
1888 1889 if repo_name:
1889 1890 # process patterns on comments if we pass in repo name
1890 1891 source, issues, errors = process_patterns(
1891 1892 source, repo_name, link_format='markdown',
1892 1893 active_entries=active_pattern_entries)
1893 1894 if issues_container_callback is not None:
1894 1895 for issue in issues:
1895 1896 issues_container_callback(issue)
1896 1897
1897 1898 rendered_block = maybe_convert_relative_links(
1898 1899 MarkupRenderer.markdown(source, flavored=True, mentions=mentions))
1899 1900 return literal(f'<div class="markdown-block">{rendered_block}</div>')
1900 1901
1901 1902 elif renderer == 'jupyter':
1902 1903 rendered_block = maybe_convert_relative_links(
1903 1904 MarkupRenderer.jupyter(source))
1904 1905 return literal(f'<div class="ipynb">{rendered_block}</div>')
1905 1906
1906 1907 # None means just show the file-source
1907 1908 return None
1908 1909
1909 1910
1910 1911 def commit_status(repo, commit_id):
1911 1912 return ChangesetStatusModel().get_status(repo, commit_id)
1912 1913
1913 1914
1914 1915 def commit_status_lbl(commit_status):
1915 1916 return dict(ChangesetStatus.STATUSES).get(commit_status)
1916 1917
1917 1918
1918 1919 def commit_time(repo_name, commit_id):
1919 1920 repo = Repository.get_by_repo_name(repo_name)
1920 1921 commit = repo.get_commit(commit_id=commit_id)
1921 1922 return commit.date
1922 1923
1923 1924
1924 1925 def get_permission_name(key):
1925 1926 return dict(Permission.PERMS).get(key)
1926 1927
1927 1928
1928 1929 def journal_filter_help(request):
1929 1930 _ = request.translate
1930 1931 from rhodecode.lib.audit_logger import ACTIONS
1931 1932 actions = '\n'.join(textwrap.wrap(', '.join(sorted(ACTIONS.keys())), 80))
1932 1933
1933 1934 return _(
1934 1935 'Example filter terms:\n' +
1935 1936 ' repository:vcs\n' +
1936 1937 ' username:marcin\n' +
1937 1938 ' username:(NOT marcin)\n' +
1938 1939 ' action:*push*\n' +
1939 1940 ' ip:127.0.0.1\n' +
1940 1941 ' date:20120101\n' +
1941 1942 ' date:[20120101100000 TO 20120102]\n' +
1942 1943 '\n' +
1943 1944 'Actions: {actions}\n' +
1944 1945 '\n' +
1945 1946 'Generate wildcards using \'*\' character:\n' +
1946 1947 ' "repository:vcs*" - search everything starting with \'vcs\'\n' +
1947 1948 ' "repository:*vcs*" - search for repository containing \'vcs\'\n' +
1948 1949 '\n' +
1949 1950 'Optional AND / OR operators in queries\n' +
1950 1951 ' "repository:vcs OR repository:test"\n' +
1951 1952 ' "username:test AND repository:test*"\n'
1952 1953 ).format(actions=actions)
1953 1954
1954 1955
1955 1956 def not_mapped_error(repo_name):
1956 1957 from rhodecode.translation import _
1957 1958 flash(_('%s repository is not mapped to db perhaps'
1958 1959 ' it was created or renamed from the filesystem'
1959 1960 ' please run the application again'
1960 1961 ' in order to rescan repositories') % repo_name, category='error')
1961 1962
1962 1963
1963 1964 def ip_range(ip_addr):
1964 1965 from rhodecode.model.db import UserIpMap
1965 1966 s, e = UserIpMap._get_ip_range(ip_addr)
1966 1967 return '%s - %s' % (s, e)
1967 1968
1968 1969
1969 1970 def form(url, method='post', needs_csrf_token=True, **attrs):
1970 1971 """Wrapper around webhelpers.tags.form to prevent CSRF attacks."""
1971 1972 if method.lower() != 'get' and needs_csrf_token:
1972 1973 raise Exception(
1973 1974 'Forms to POST/PUT/DELETE endpoints should have (in general) a ' +
1974 1975 'CSRF token. If the endpoint does not require such token you can ' +
1975 1976 'explicitly set the parameter needs_csrf_token to false.')
1976 1977
1977 1978 return insecure_form(url, method=method, **attrs)
1978 1979
1979 1980
1980 1981 def secure_form(form_url, method="POST", multipart=False, **attrs):
1981 1982 """Start a form tag that points the action to an url. This
1982 1983 form tag will also include the hidden field containing
1983 1984 the auth token.
1984 1985
1985 1986 The url options should be given either as a string, or as a
1986 1987 ``url()`` function. The method for the form defaults to POST.
1987 1988
1988 1989 Options:
1989 1990
1990 1991 ``multipart``
1991 1992 If set to True, the enctype is set to "multipart/form-data".
1992 1993 ``method``
1993 1994 The method to use when submitting the form, usually either
1994 1995 "GET" or "POST". If "PUT", "DELETE", or another verb is used, a
1995 1996 hidden input with name _method is added to simulate the verb
1996 1997 over POST.
1997 1998
1998 1999 """
1999 2000
2000 2001 if 'request' in attrs:
2001 2002 session = attrs['request'].session
2002 2003 del attrs['request']
2003 2004 else:
2004 2005 raise ValueError(
2005 2006 'Calling this form requires request= to be passed as argument')
2006 2007
2007 2008 _form = insecure_form(form_url, method, multipart, **attrs)
2008 2009 token = literal(
2009 2010 '<input type="hidden" name="{}" value="{}">'.format(
2010 2011 csrf_token_key, get_csrf_token(session)))
2011 2012
2012 2013 return literal("%s\n%s" % (_form, token))
2013 2014
2014 2015
2015 2016 def dropdownmenu(name, selected, options, enable_filter=False, **attrs):
2016 2017 select_html = select(name, selected, options, **attrs)
2017 2018
2018 2019 select2 = """
2019 2020 <script>
2020 2021 $(document).ready(function() {
2021 2022 $('#%s').select2({
2022 2023 containerCssClass: 'drop-menu %s',
2023 2024 dropdownCssClass: 'drop-menu-dropdown',
2024 2025 dropdownAutoWidth: true%s
2025 2026 });
2026 2027 });
2027 2028 </script>
2028 2029 """
2029 2030
2030 2031 filter_option = """,
2031 2032 minimumResultsForSearch: -1
2032 2033 """
2033 2034 input_id = attrs.get('id') or name
2034 2035 extra_classes = ' '.join(attrs.pop('extra_classes', []))
2035 2036 filter_enabled = "" if enable_filter else filter_option
2036 2037 select_script = literal(select2 % (input_id, extra_classes, filter_enabled))
2037 2038
2038 2039 return literal(select_html+select_script)
2039 2040
2040 2041
2041 2042 def get_visual_attr(tmpl_context_var, attr_name):
2042 2043 """
2043 2044 A safe way to get a variable from visual variable of template context
2044 2045
2045 2046 :param tmpl_context_var: instance of tmpl_context, usually present as `c`
2046 2047 :param attr_name: name of the attribute we fetch from the c.visual
2047 2048 """
2048 2049 visual = getattr(tmpl_context_var, 'visual', None)
2049 2050 if not visual:
2050 2051 return
2051 2052 else:
2052 2053 return getattr(visual, attr_name, None)
2053 2054
2054 2055
2055 2056 def get_last_path_part(file_node):
2056 2057 if not file_node.path:
2057 2058 return '/'
2058 2059
2059 2060 path = safe_str(file_node.path.split('/')[-1])
2060 2061 return '../' + path
2061 2062
2062 2063
2063 2064 def route_url(*args, **kwargs):
2064 2065 """
2065 2066 Wrapper around pyramids `route_url` (fully qualified url) function.
2066 2067 """
2067 2068 req = get_current_request()
2068 2069 return req.route_url(*args, **kwargs)
2069 2070
2070 2071
2071 2072 def route_path(*args, **kwargs):
2072 2073 """
2073 2074 Wrapper around pyramids `route_path` function.
2074 2075 """
2075 2076 req = get_current_request()
2076 2077 return req.route_path(*args, **kwargs)
2077 2078
2078 2079
2079 2080 def route_path_or_none(*args, **kwargs):
2080 2081 try:
2081 2082 return route_path(*args, **kwargs)
2082 2083 except KeyError:
2083 2084 return None
2084 2085
2085 2086
2086 2087 def current_route_path(request, **kw):
2087 2088 new_args = request.GET.mixed()
2088 2089 new_args.update(kw)
2089 2090 return request.current_route_path(_query=new_args)
2090 2091
2091 2092
2092 2093 def curl_api_example(method, args):
2093 2094 args_json = json.dumps(OrderedDict([
2094 2095 ('id', 1),
2095 2096 ('auth_token', 'SECRET'),
2096 2097 ('method', method),
2097 2098 ('args', args)
2098 2099 ]))
2099 2100
2100 2101 return "curl {api_url} -X POST -H 'content-type:text/plain' --data-binary '{args_json}'".format(
2101 2102 api_url=route_url('apiv2'),
2102 2103 args_json=args_json
2103 2104 )
2104 2105
2105 2106
2106 2107 def api_call_example(method, args):
2107 2108 """
2108 2109 Generates an API call example via CURL
2109 2110 """
2110 2111 curl_call = curl_api_example(method, args)
2111 2112
2112 2113 return literal(
2113 2114 curl_call +
2114 2115 "<br/><br/>SECRET can be found in <a href=\"{token_url}\">auth-tokens</a> page, "
2115 2116 "and needs to be of `api calls` role."
2116 2117 .format(token_url=route_url('my_account_auth_tokens')))
2117 2118
2118 2119
2119 2120 def notification_description(notification, request):
2120 2121 """
2121 2122 Generate notification human readable description based on notification type
2122 2123 """
2123 2124 from rhodecode.model.notification import NotificationModel
2124 2125 return NotificationModel().make_description(
2125 2126 notification, translate=request.translate)
2126 2127
2127 2128
2128 2129 def go_import_header(request, db_repo=None):
2129 2130 """
2130 2131 Creates a header for go-import functionality in Go Lang
2131 2132 """
2132 2133
2133 2134 if not db_repo:
2134 2135 return
2135 2136 if 'go-get' not in request.GET:
2136 2137 return
2137 2138
2138 2139 clone_url = db_repo.clone_url()
2139 2140 prefix = re.split(r'^https?:\/\/', clone_url)[-1]
2140 2141 # we have a repo and go-get flag,
2141 2142 return literal('<meta name="go-import" content="{} {} {}">'.format(
2142 2143 prefix, db_repo.repo_type, clone_url))
2143 2144
2144 2145
2145 2146 def reviewer_as_json(*args, **kwargs):
2146 2147 from rhodecode.apps.repository.utils import reviewer_as_json as _reviewer_as_json
2147 2148 return _reviewer_as_json(*args, **kwargs)
2148 2149
2149 2150
2150 2151 def get_repo_view_type(request):
2151 2152 route_name = request.matched_route.name
2152 2153 route_to_view_type = {
2153 2154 'repo_changelog': 'commits',
2154 2155 'repo_commits': 'commits',
2155 2156 'repo_files': 'files',
2156 2157 'repo_summary': 'summary',
2157 2158 'repo_commit': 'commit'
2158 2159 }
2159 2160
2160 2161 return route_to_view_type.get(route_name)
2161 2162
2162 2163
2163 2164 def is_active(menu_entry, selected):
2164 2165 """
2165 2166 Returns active class for selecting menus in templates
2166 2167 <li class=${h.is_active('settings', current_active)}></li>
2167 2168 """
2168 2169 if not isinstance(menu_entry, list):
2169 2170 menu_entry = [menu_entry]
2170 2171
2171 2172 if selected in menu_entry:
2172 2173 return "active"
2173 2174
2174 2175
2175 2176 class IssuesRegistry(object):
2176 2177 """
2177 2178 issue_registry = IssuesRegistry()
2178 2179 some_func(issues_callback=issues_registry(...))
2179 2180 """
2180 2181
2181 2182 def __init__(self):
2182 2183 self.issues = []
2183 2184 self.unique_issues = collections.defaultdict(lambda: [])
2184 2185
2185 2186 def __call__(self, commit_dict=None):
2186 2187 def callback(issue):
2187 2188 if commit_dict and issue:
2188 2189 issue['commit'] = commit_dict
2189 2190 self.issues.append(issue)
2190 2191 self.unique_issues[issue['id']].append(issue)
2191 2192 return callback
2192 2193
2193 2194 def get_issues(self):
2194 2195 return self.issues
2195 2196
2196 2197 @property
2197 2198 def issues_unique_count(self):
2198 2199 return len(set(i['id'] for i in self.issues))
@@ -1,240 +1,240 b''
1 1
2 2 # Copyright (C) 2010-2023 RhodeCode GmbH
3 3 #
4 4 # This program is free software: you can redistribute it and/or modify
5 5 # it under the terms of the GNU Affero General Public License, version 3
6 6 # (only), as published by the Free Software Foundation.
7 7 #
8 8 # This program is distributed in the hope that it will be useful,
9 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 11 # GNU General Public License for more details.
12 12 #
13 13 # You should have received a copy of the GNU Affero General Public License
14 14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 15 #
16 16 # This program is dual-licensed. If you wish to learn more about the
17 17 # RhodeCode Enterprise Edition, including its added features, Support services,
18 18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 19
20 20 import copy
21 21 import mock
22 22 import pytest
23 23
24 24 from rhodecode.lib import helpers
25 25 from rhodecode.lib.utils2 import AttributeDict
26 26 from rhodecode.model.settings import IssueTrackerSettingsModel
27 27 from rhodecode.tests import no_newline_id_generator
28 28
29 29
30 30 @pytest.mark.parametrize('url, expected_url', [
31 (r'https://rc.com', '<a href="https://rc.com">http://rc.com</a>'),
31 (r'https://rc.com', '<a href="https://rc.com">https://rc.com</a>'),
32 32 (r'https://rc.com/test', '<a href="https://rc.com/test">https://rc.com/test</a>'),
33 33 (r'https://rc.com/!foo', '<a href="https://rc.com/!foo">https://rc.com/!foo</a>'),
34 34 (r'https://rc.com/&foo', '<a href="https://rc.com/&amp;foo">https://rc.com/&amp;foo</a>'),
35 35 (r'https://rc.com/?foo-1&bar=1', '<a href="https://rc.com/?foo-1&amp;bar=1">https://rc.com/?foo-1&amp;bar=1</a>'),
36 36 (r'https://rc.com?foo-1&bar=1', '<a href="https://rc.com?foo-1&amp;bar=1">https://rc.com?foo-1&amp;bar=1</a>'),
37 37 (r'https://rc.com/#foo', '<a href="https://rc.com/#foo">https://rc.com/#foo</a>'),
38 38 (r'https://rc.com/@foo', '<a href="https://rc.com/@foo">https://rc.com/@foo</a>'),
39 39 ])
40 40 def test_urlify_text(url, expected_url):
41 41 assert helpers.urlify_text(url) == expected_url
42 42
43 43
44 44 @pytest.mark.parametrize('repo_name, commit_id, path, expected_result', [
45 45 # Simple case 1
46 46 ('repo', 'commit', 'a/b',
47 47 '<a href="/repo/files/commit/"><i class="icon-home"></i></a>'
48 48 ' / '
49 49 '<a href="/repo/files/commit/a">a</a>'
50 50 ' / '
51 51 'b'),
52 52
53 53 # Simple case
54 54 ('rX<X', 'cX<X', 'pX<X/aX<X/bX<X',
55 55 '<a href="/rX%3CX/files/cX%3CX/"><i class="icon-home"></i></a>'
56 56 ' / '
57 57 '<a href="/rX%3CX/files/cX%3CX/pX%3CX">pX&lt;X</a>'
58 58 ' / '
59 59 '<a href="/rX%3CX/files/cX%3CX/pX%3CX/aX%3CX">aX&lt;X</a>'
60 60 ' / '
61 61 'bX&lt;X'),
62 62
63 63 # Path with only one segment
64 64 ('rX<X', 'cX<X', 'pX<X',
65 65 '<a href="/rX%3CX/files/cX%3CX/"><i class="icon-home"></i></a>'
66 66 ' / '
67 67 'pX&lt;X'),
68 68
69 69 # Empty path
70 70 ('rX<X', 'cX<X', '',
71 71 '<i class="icon-home"></i>'),
72 72
73 73 # simple quote
74 74 ('rX"X', 'cX"X', 'pX"X/aX"X/bX"X',
75 75 '<a href="/rX%22X/files/cX%22X/"><i class="icon-home"></i></a>'
76 76 ' / '
77 77 '<a href="/rX%22X/files/cX%22X/pX%22X">pX&#34;X</a>'
78 78 ' / '
79 79 '<a href="/rX%22X/files/cX%22X/pX%22X/aX%22X">aX&#34;X</a>'
80 80 ' / '
81 81 'bX&#34;X'),
82 82
83 83 ], ids=['simple1', 'simple2', 'one_segment', 'empty_path', 'simple_quote'])
84 84 def test_files_breadcrumbs_xss(repo_name, commit_id, path, app, expected_result):
85 85 result = helpers.files_breadcrumbs(repo_name, 'hg', commit_id, path)
86 86 # Expect it to encode all path fragments properly. This is important
87 87 # because it returns an instance of `literal`.
88 88 if path != '':
89 89 expected_result = expected_result + helpers.files_icon.format(helpers.escape(path))
90 90 assert result == expected_result
91 91
92 92
93 93 def test_format_binary():
94 94 assert helpers.format_byte_size_binary(298489462784) == '278.0 GiB'
95 95
96 96
97 97 @pytest.mark.parametrize('text_string, pattern, expected', [
98 98 ('No issue here', r'(?:#)(?P<issue_id>\d+)', []),
99 99 ('Fix #42', '(?:#)(?P<issue_id>\d+)',
100 100 [{'url': 'https://r.io/{repo}/i/42', 'id': '42'}]),
101 101 ('Fix #42, #53', '(?:#)(?P<issue_id>\d+)', [
102 102 {'url': 'https://r.io/{repo}/i/42', 'id': '42'},
103 103 {'url': 'https://r.io/{repo}/i/53', 'id': '53'}]),
104 104 ('Fix #42', '(?:#)?<issue_id>\d+)', []), # Broken regex
105 105 ])
106 106 def test_extract_issues(backend, text_string, pattern, expected):
107 107 repo = backend.create_repo()
108 108 config = {
109 109 '123': {
110 110 'uid': '123',
111 111 'pat': pattern,
112 112 'url': r'https://r.io/${repo}/i/${issue_id}',
113 113 'pref': '#',
114 114 'desc': 'Test Pattern'
115 115 }
116 116 }
117 117
118 118 def get_settings_mock(self, cache=True):
119 119 return config
120 120
121 121 with mock.patch.object(IssueTrackerSettingsModel,
122 122 'get_settings', get_settings_mock):
123 123 text, issues, errors = helpers.process_patterns(text_string, repo.repo_name)
124 124
125 125 expected = copy.deepcopy(expected)
126 126 for item in expected:
127 127 item['url'] = item['url'].format(repo=repo.repo_name)
128 128
129 129 assert issues == expected
130 130
131 131
132 132 @pytest.mark.parametrize('text_string, pattern, link_format, expected_text', [
133 133 ('Fix #42', '(?:#)(?P<issue_id>\d+)', 'html',
134 'Fix <a class="tooltip issue-tracker-link" href="http://r.io/{repo}/i/42" title="Test Pattern">#42</a>'),
134 'Fix <a class="tooltip issue-tracker-link" href="https://r.io/{repo}/i/42" title="Test Pattern">#42</a>'),
135 135
136 136 ('Fix #42', '(?:#)(?P<issue_id>\d+)', 'markdown',
137 'Fix [#42](http://r.io/{repo}/i/42)'),
137 'Fix [#42](https://r.io/{repo}/i/42)'),
138 138
139 139 ('Fix #42', '(?:#)(?P<issue_id>\d+)', 'rst',
140 'Fix `#42 <http://r.io/{repo}/i/42>`_'),
140 'Fix `#42 <https://r.io/{repo}/i/42>`_'),
141 141
142 142 ('Fix #42', '(?:#)?<issue_id>\d+)', 'html',
143 143 'Fix #42'), # Broken regex
144 144 ])
145 145 def test_process_patterns_repo(backend, text_string, pattern, expected_text, link_format):
146 146 repo = backend.create_repo()
147 147
148 148 def get_settings_mock(self, cache=True):
149 149 return {
150 150 '123': {
151 151 'uid': '123',
152 152 'pat': pattern,
153 'url': 'http://r.io/${repo}/i/${issue_id}',
153 'url': 'https://r.io/${repo}/i/${issue_id}',
154 154 'pref': '#',
155 155 'desc': 'Test Pattern'
156 156 }
157 157 }
158 158
159 159 with mock.patch.object(IssueTrackerSettingsModel,
160 160 'get_settings', get_settings_mock):
161 161 processed_text, issues, error = helpers.process_patterns(
162 162 text_string, repo.repo_name, link_format)
163 163
164 164 assert processed_text == expected_text.format(repo=repo.repo_name)
165 165
166 166
167 167 @pytest.mark.parametrize('text_string, pattern, expected_text', [
168 168 ('Fix #42', '(?:#)(?P<issue_id>\d+)',
169 'Fix <a class="tooltip issue-tracker-link" href="http://r.io/i/42" title="Test Pattern">#42</a>'),
169 'Fix <a class="tooltip issue-tracker-link" href="https://r.io/i/42" title="Test Pattern">#42</a>'),
170 170 ('Fix #42', '(?:#)?<issue_id>\d+)',
171 171 'Fix #42'), # Broken regex
172 172 ])
173 173 def test_process_patterns_no_repo(text_string, pattern, expected_text):
174 174
175 175 def get_settings_mock(self, cache=True):
176 176 return {
177 177 '123': {
178 178 'uid': '123',
179 179 'pat': pattern,
180 'url': 'http://r.io/i/${issue_id}',
180 'url': 'https://r.io/i/${issue_id}',
181 181 'pref': '#',
182 182 'desc': 'Test Pattern'
183 183 }
184 184 }
185 185
186 186 with mock.patch.object(IssueTrackerSettingsModel,
187 187 'get_global_settings', get_settings_mock):
188 188 processed_text, issues, errors = helpers.process_patterns(
189 189 text_string, '')
190 190
191 191 assert processed_text == expected_text
192 192
193 193
194 194 def test_process_patterns_non_existent_repo_name(backend):
195 195 text_string = 'Fix #42'
196 pattern = '(?:#)(?P<issue_id>\d+)'
196 pattern = r'(?:#)(?P<issue_id>\d+)'
197 197 expected_text = ('Fix <a class="tooltip issue-tracker-link" '
198 'href="http://r.io/do-not-exist/i/42" title="Test Pattern">#42</a>')
198 'href="https://r.io/do-not-exist/i/42" title="Test Pattern">#42</a>')
199 199
200 200 def get_settings_mock(self, cache=True):
201 201 return {
202 202 '123': {
203 203 'uid': '123',
204 204 'pat': pattern,
205 'url': 'http://r.io/${repo}/i/${issue_id}',
205 'url': 'https://r.io/${repo}/i/${issue_id}',
206 206 'pref': '#',
207 207 'desc': 'Test Pattern'
208 208 }
209 209 }
210 210
211 211 with mock.patch.object(IssueTrackerSettingsModel,
212 212 'get_global_settings', get_settings_mock):
213 213 processed_text, issues, errors = helpers.process_patterns(
214 214 text_string, 'do-not-exist')
215 215
216 216 assert processed_text == expected_text
217 217
218 218
219 219 def test_get_visual_attr(baseapp):
220 220 from rhodecode.apps._base import TemplateArgs
221 221 c = TemplateArgs()
222 222 assert None is helpers.get_visual_attr(c, 'fakse')
223 223
224 224 # emulate the c.visual behaviour
225 225 c.visual = AttributeDict({})
226 226 assert None is helpers.get_visual_attr(c, 'some_var')
227 227
228 228 c.visual.some_var = 'foobar'
229 229 assert 'foobar' == helpers.get_visual_attr(c, 'some_var')
230 230
231 231
232 232 @pytest.mark.parametrize('test_text, inclusive, expected_text', [
233 233 ('just a string', False, 'just a string'),
234 234 ('just a string\n', False, 'just a string'),
235 235 ('just a string\n next line', False, 'just a string...'),
236 236 ('just a string\n next line', True, 'just a string\n...'),
237 237 ], ids=no_newline_id_generator)
238 238 def test_chop_at(test_text, inclusive, expected_text):
239 239 assert helpers.chop_at_smart(
240 240 test_text, '\n', inclusive, '...') == expected_text
General Comments 0
You need to be logged in to leave comments. Login now