##// END OF EJS Templates
cleanup: few fixes
super-admin -
r5116:cc48fcd2 default
parent child Browse files
Show More
@@ -1,1056 +1,1062 b''
1 1
2 2
3 3 # Copyright (c) 2007-2012 Christoph Haas <email@christoph-haas.de>
4 4 # NOTE: MIT license based code, backported and edited by RhodeCode GmbH
5 5
6 6 """
7 7 paginate: helps split up large collections into individual pages
8 8 ================================================================
9 9
10 10 What is pagination?
11 11 ---------------------
12 12
13 13 This module helps split large lists of items into pages. The user is shown one page at a time and
14 14 can navigate to other pages. Imagine you are offering a company phonebook and let the user search
15 15 the entries. The entire search result may contains 23 entries but you want to display no more than
16 16 10 entries at once. The first page contains entries 1-10, the second 11-20 and the third 21-23.
17 17 Each "Page" instance represents the items of one of these three pages.
18 18
19 19 See the documentation of the "Page" class for more information.
20 20
21 21 How do I use it?
22 22 ------------------
23 23
24 24 A page of items is represented by the *Page* object. A *Page* gets initialized with these arguments:
25 25
26 26 - The collection of items to pick a range from. Usually just a list.
27 27 - The page number you want to display. Default is 1: the first page.
28 28
29 29 Now we can make up a collection and create a Page instance of it::
30 30
31 31 # Create a sample collection of 1000 items
32 32 >> my_collection = range(1000)
33 33
34 34 # Create a Page object for the 3rd page (20 items per page is the default)
35 35 >> my_page = Page(my_collection, page=3)
36 36
37 37 # The page object can be printed as a string to get its details
38 38 >> str(my_page)
39 39 Page:
40 40 Collection type: <type 'range'>
41 41 Current page: 3
42 42 First item: 41
43 43 Last item: 60
44 44 First page: 1
45 45 Last page: 50
46 46 Previous page: 2
47 47 Next page: 4
48 48 Items per page: 20
49 49 Number of items: 1000
50 50 Number of pages: 50
51 51
52 52 # Print a list of items on the current page
53 53 >> my_page.items
54 54 [40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59]
55 55
56 56 # The *Page* object can be used as an iterator:
57 57 >> for my_item in my_page: print(my_item)
58 58 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
59 59
60 60 # The .pager() method returns an HTML fragment with links to surrounding pages.
61 61 >> my_page.pager(url="http://example.org/foo/page=$page")
62 62
63 63 <a href="http://example.org/foo/page=1">1</a>
64 64 <a href="http://example.org/foo/page=2">2</a>
65 65 3
66 66 <a href="http://example.org/foo/page=4">4</a>
67 67 <a href="http://example.org/foo/page=5">5</a>
68 68 ..
69 69 <a href="http://example.org/foo/page=50">50</a>'
70 70
71 71 # Without the HTML it would just look like:
72 72 # 1 2 [3] 4 5 .. 50
73 73
74 74 # The pager can be customized:
75 75 >> my_page.pager('$link_previous ~3~ $link_next (Page $page of $page_count)',
76 76 url="http://example.org/foo/page=$page")
77 77
78 78 <a href="http://example.org/foo/page=2">&lt;</a>
79 79 <a href="http://example.org/foo/page=1">1</a>
80 80 <a href="http://example.org/foo/page=2">2</a>
81 81 3
82 82 <a href="http://example.org/foo/page=4">4</a>
83 83 <a href="http://example.org/foo/page=5">5</a>
84 84 <a href="http://example.org/foo/page=6">6</a>
85 85 ..
86 86 <a href="http://example.org/foo/page=50">50</a>
87 87 <a href="http://example.org/foo/page=4">&gt;</a>
88 88 (Page 3 of 50)
89 89
90 90 # Without the HTML it would just look like:
91 91 # 1 2 [3] 4 5 6 .. 50 > (Page 3 of 50)
92 92
93 93 # The url argument to the pager method can be omitted when an url_maker is
94 94 # given during instantiation:
95 95 >> my_page = Page(my_collection, page=3,
96 96 url_maker=lambda p: "http://example.org/%s" % p)
97 97 >> page.pager()
98 98
99 99 There are some interesting parameters that customize the Page's behavior. See the documentation on
100 100 ``Page`` and ``Page.pager()``.
101 101
102 102
103 103 Notes
104 104 -------
105 105
106 106 Page numbers and item numbers start at 1. This concept has been used because users expect that the
107 107 first page has number 1 and the first item on a page also has number 1. So if you want to use the
108 108 page's items by their index number please note that you have to subtract 1.
109 109 """
110 110
111 111 import re
112 112 from string import Template
113 113 from webhelpers2.html import literal
114 114
115 115
116 116 def make_html_tag(tag, text=None, **params):
117 117 """Create an HTML tag string.
118 118
119 119 tag
120 120 The HTML tag to use (e.g. 'a', 'span' or 'div')
121 121
122 122 text
123 123 The text to enclose between opening and closing tag. If no text is specified then only
124 124 the opening tag is returned.
125 125
126 126 Example::
127 127 make_html_tag('a', text="Hello", href="/another/page")
128 128 -> <a href="/another/page">Hello</a>
129 129
130 130 To use reserved Python keywords like "class" as a parameter prepend it with
131 131 an underscore. Instead of "class='green'" use "_class='green'".
132 132
133 133 Warning: Quotes and apostrophes are not escaped."""
134 134 params_string = ""
135 135
136 136 # Parameters are passed. Turn the dict into a string like "a=1 b=2 c=3" string.
137 137 for key, value in sorted(params.items()):
138 138 # Strip off a leading underscore from the attribute's key to allow attributes like '_class'
139 139 # to be used as a CSS class specification instead of the reserved Python keyword 'class'.
140 140 key = key.lstrip("_")
141 141
142 142 params_string += ' {0}="{1}"'.format(key, value)
143 143
144 144 # Create the tag string
145 145 tag_string = "<{0}{1}>".format(tag, params_string)
146 146
147 147 # Add text and closing tag if required.
148 148 if text:
149 149 tag_string += "{0}</{1}>".format(text, tag)
150 150
151 151 return tag_string
152 152
153 153
154 154 # Since the items on a page are mainly a list we subclass the "list" type
155 155 class _Page(list):
156 156 """A list/iterator representing the items on one page of a larger collection.
157 157
158 158 An instance of the "Page" class is created from a _collection_ which is any
159 159 list-like object that allows random access to its elements.
160 160
161 161 The instance works as an iterator running from the first item to the last item on the given
162 162 page. The Page.pager() method creates a link list allowing the user to go to other pages.
163 163
164 164 A "Page" does not only carry the items on a certain page. It gives you additional information
165 165 about the page in these "Page" object attributes:
166 166
167 167 item_count
168 168 Number of items in the collection
169 169
170 170 **WARNING:** Unless you pass in an item_count, a count will be
171 171 performed on the collection every time a Page instance is created.
172 172
173 173 page
174 174 Number of the current page
175 175
176 176 items_per_page
177 177 Maximal number of items displayed on a page
178 178
179 179 first_page
180 180 Number of the first page - usually 1 :)
181 181
182 182 last_page
183 183 Number of the last page
184 184
185 185 previous_page
186 186 Number of the previous page. If this is the first page it returns None.
187 187
188 188 next_page
189 189 Number of the next page. If this is the last page it returns None.
190 190
191 191 page_count
192 192 Number of pages
193 193
194 194 items
195 195 Sequence/iterator of items on the current page
196 196
197 197 first_item
198 198 Index of first item on the current page - starts with 1
199 199
200 200 last_item
201 201 Index of last item on the current page
202 202 """
203 203
204 204 def __init__(
205 205 self,
206 206 collection,
207 207 page=1,
208 208 items_per_page=20,
209 209 item_count=None,
210 210 wrapper_class=None,
211 211 url_maker=None,
212 212 bar_size=10,
213 213 **kwargs
214 214 ):
215 215 """Create a "Page" instance.
216 216
217 217 Parameters:
218 218
219 219 collection
220 220 Sequence representing the collection of items to page through.
221 221
222 222 page
223 223 The requested page number - starts with 1. Default: 1.
224 224
225 225 items_per_page
226 226 The maximal number of items to be displayed per page.
227 227 Default: 20.
228 228
229 229 item_count (optional)
230 230 The total number of items in the collection - if known.
231 231 If this parameter is not given then the paginator will count
232 232 the number of elements in the collection every time a "Page"
233 233 is created. Giving this parameter will speed up things. In a busy
234 234 real-life application you may want to cache the number of items.
235 235
236 236 url_maker (optional)
237 237 Callback to generate the URL of other pages, given its numbers.
238 238 Must accept one int parameter and return a URI string.
239 239
240 240 bar_size
241 241 maximum size of rendered pages numbers within radius
242 242
243 243 """
244 244 if collection is not None:
245 245 if wrapper_class is None:
246 246 # Default case. The collection is already a list-type object.
247 247 self.collection = collection
248 248 else:
249 249 # Special case. A custom wrapper class is used to access elements of the collection.
250 250 self.collection = wrapper_class(collection)
251 251 else:
252 252 self.collection = []
253 253
254 254 self.collection_type = type(collection)
255 255
256 256 if url_maker is not None:
257 257 self.url_maker = url_maker
258 258 else:
259 259 self.url_maker = self._default_url_maker
260 260 self.bar_size = bar_size
261 261 # Assign kwargs to self
262 262 self.kwargs = kwargs
263 263
264 264 # The self.page is the number of the current page.
265 265 # The first page has the number 1!
266 266 try:
267 267 self.page = int(page) # make it int() if we get it as a string
268 268 except (ValueError, TypeError):
269 269 self.page = 1
270 270 # normally page should be always at least 1 but the original maintainer
271 271 # decided that for empty collection and empty page it can be...0? (based on tests)
272 272 # preserving behavior for BW compat
273 273 if self.page < 1:
274 274 self.page = 1
275 275
276 276 self.items_per_page = items_per_page
277 277
278 278 # We subclassed "list" so we need to call its init() method
279 279 # and fill the new list with the items to be displayed on the page.
280 280 # We use list() so that the items on the current page are retrieved
281 281 # only once. In an SQL context that could otherwise lead to running the
282 282 # same SQL query every time items would be accessed.
283 283 # We do this here, prior to calling len() on the collection so that a
284 284 # wrapper class can execute a query with the knowledge of what the
285 285 # slice will be (for efficiency) and, in the same query, ask for the
286 286 # total number of items and only execute one query.
287 287
288 288 try:
289 289 first = (self.page - 1) * items_per_page
290 290 last = first + items_per_page
291 291 self.items = list(self.collection[first:last])
292 292 except TypeError as err:
293 293 raise TypeError(
294 294 f"Your collection of type {type(self.collection)} cannot be handled "
295 295 f"by paginate. ERROR:{err}"
296 296 )
297 297
298 298 # Unless the user tells us how many items the collections has
299 299 # we calculate that ourselves.
300 300 if item_count is not None:
301 301 self.item_count = item_count
302 302 else:
303 303 self.item_count = len(self.collection)
304 304
305 305 # Compute the number of the first and last available page
306 306 if self.item_count > 0:
307 307 self.first_page = 1
308 308 self.page_count = ((self.item_count - 1) // self.items_per_page) + 1
309 309 self.last_page = self.first_page + self.page_count - 1
310 310
311 311 # Make sure that the requested page number is the range of valid pages
312 312 if self.page > self.last_page:
313 313 self.page = self.last_page
314 314 elif self.page < self.first_page:
315 315 self.page = self.first_page
316 316
317 317 # Note: the number of items on this page can be less than
318 318 # items_per_page if the last page is not full
319 319 self.first_item = (self.page - 1) * items_per_page + 1
320 320 self.last_item = min(self.first_item + items_per_page - 1, self.item_count)
321 321
322 322 # Links to previous and next page
323 323 if self.page > self.first_page:
324 324 self.previous_page = self.page - 1
325 325 else:
326 326 self.previous_page = None
327 327
328 328 if self.page < self.last_page:
329 329 self.next_page = self.page + 1
330 330 else:
331 331 self.next_page = None
332 332
333 333 # No items available
334 334 else:
335 335 self.first_page = None
336 336 self.page_count = 0
337 337 self.last_page = None
338 338 self.first_item = None
339 339 self.last_item = None
340 340 self.previous_page = None
341 341 self.next_page = None
342 342 self.items = []
343 343
344 344 # This is a subclass of the 'list' type. Initialise the list now.
345 345 list.__init__(self, self.items)
346 346
347 347 def __str__(self):
348 348 return (
349 349 "Page:\n"
350 350 "Collection type: {0.collection_type}\n"
351 351 "Current page: {0.page}\n"
352 352 "First item: {0.first_item}\n"
353 353 "Last item: {0.last_item}\n"
354 354 "First page: {0.first_page}\n"
355 355 "Last page: {0.last_page}\n"
356 356 "Previous page: {0.previous_page}\n"
357 357 "Next page: {0.next_page}\n"
358 358 "Items per page: {0.items_per_page}\n"
359 359 "Total number of items: {0.item_count}\n"
360 360 "Number of pages: {0.page_count}\n"
361 361 ).format(self)
362 362
363 363 def __repr__(self):
364 364 return "<paginate.Page: Page {0}/{1}>".format(self.page, self.page_count)
365 365
366 366 def pager(
367 367 self,
368 368 tmpl_format="~2~",
369 369 url=None,
370 370 show_if_single_page=False,
371 371 separator=" ",
372 372 symbol_first="&lt;&lt;",
373 373 symbol_last="&gt;&gt;",
374 374 symbol_previous="&lt;",
375 375 symbol_next="&gt;",
376 376 link_attr=None,
377 377 curpage_attr=None,
378 378 dotdot_attr=None,
379 379 link_tag=None,
380 380 ):
381 381 """
382 382 Return string with links to other pages (e.g. '1 .. 5 6 7 [8] 9 10 11 .. 50').
383 383
384 384 tmpl_format:
385 385 Format string that defines how the pager is rendered. The string
386 386 can contain the following $-tokens that are substituted by the
387 387 string.Template module:
388 388
389 389 - $first_page: number of first reachable page
390 390 - $last_page: number of last reachable page
391 391 - $page: number of currently selected page
392 392 - $page_count: number of reachable pages
393 393 - $items_per_page: maximal number of items per page
394 394 - $first_item: index of first item on the current page
395 395 - $last_item: index of last item on the current page
396 396 - $item_count: total number of items
397 397 - $link_first: link to first page (unless this is first page)
398 398 - $link_last: link to last page (unless this is last page)
399 399 - $link_previous: link to previous page (unless this is first page)
400 400 - $link_next: link to next page (unless this is last page)
401 401
402 402 To render a range of pages the token '~3~' can be used. The
403 403 number sets the radius of pages around the current page.
404 404 Example for a range with radius 3:
405 405
406 406 '1 .. 5 6 7 [8] 9 10 11 .. 50'
407 407
408 408 Default: '~2~'
409 409
410 410 url
411 411 The URL that page links will point to. Make sure it contains the string
412 412 $page which will be replaced by the actual page number.
413 413 Must be given unless a url_maker is specified to __init__, in which
414 414 case this parameter is ignored.
415 415
416 416 symbol_first
417 417 String to be displayed as the text for the $link_first link above.
418 418
419 419 Default: '&lt;&lt;' (<<)
420 420
421 421 symbol_last
422 422 String to be displayed as the text for the $link_last link above.
423 423
424 424 Default: '&gt;&gt;' (>>)
425 425
426 426 symbol_previous
427 427 String to be displayed as the text for the $link_previous link above.
428 428
429 429 Default: '&lt;' (<)
430 430
431 431 symbol_next
432 432 String to be displayed as the text for the $link_next link above.
433 433
434 434 Default: '&gt;' (>)
435 435
436 436 separator:
437 437 String that is used to separate page links/numbers in the above range of pages.
438 438
439 439 Default: ' '
440 440
441 441 show_if_single_page:
442 442 if True the navigator will be shown even if there is only one page.
443 443
444 444 Default: False
445 445
446 446 link_attr (optional)
447 447 A dictionary of attributes that get added to A-HREF links pointing to other pages. Can
448 448 be used to define a CSS style or class to customize the look of links.
449 449
450 450 Example: { 'style':'border: 1px solid green' }
451 451 Example: { 'class':'pager_link' }
452 452
453 453 curpage_attr (optional)
454 454 A dictionary of attributes that get added to the current page number in the pager (which
455 455 is obviously not a link). If this dictionary is not empty then the elements will be
456 456 wrapped in a SPAN tag with the given attributes.
457 457
458 458 Example: { 'style':'border: 3px solid blue' }
459 459 Example: { 'class':'pager_curpage' }
460 460
461 461 dotdot_attr (optional)
462 462 A dictionary of attributes that get added to the '..' string in the pager (which is
463 463 obviously not a link). If this dictionary is not empty then the elements will be wrapped
464 464 in a SPAN tag with the given attributes.
465 465
466 466 Example: { 'style':'color: #808080' }
467 467 Example: { 'class':'pager_dotdot' }
468 468
469 469 link_tag (optional)
470 470 A callable that accepts single argument `page` (page link information)
471 471 and generates string with html that represents the link for specific page.
472 472 Page objects are supplied from `link_map()` so the keys are the same.
473 473
474 474
475 475 """
476 476 link_attr = link_attr or {}
477 477 curpage_attr = curpage_attr or {}
478 478 dotdot_attr = dotdot_attr or {}
479 479 self.curpage_attr = curpage_attr
480 480 self.separator = separator
481 481 self.link_attr = link_attr
482 482 self.dotdot_attr = dotdot_attr
483 483 self.url = url
484 484 self.link_tag = link_tag or self.default_link_tag
485 485
486 486 # Don't show navigator if there is no more than one page
487 487 if self.page_count == 0 or (self.page_count == 1 and not show_if_single_page):
488 488 return ""
489 489
490 490 regex_res = re.search(r"~(\d+)~", tmpl_format)
491 491 if regex_res:
492 492 radius = regex_res.group(1)
493 493 else:
494 494 radius = 2
495 495
496 496 self.radius = int(radius)
497 497 link_map = self.link_map(
498 498 tmpl_format=tmpl_format,
499 499 url=url,
500 500 show_if_single_page=show_if_single_page,
501 501 separator=separator,
502 502 symbol_first=symbol_first,
503 503 symbol_last=symbol_last,
504 504 symbol_previous=symbol_previous,
505 505 symbol_next=symbol_next,
506 506 link_attr=link_attr,
507 507 curpage_attr=curpage_attr,
508 508 dotdot_attr=dotdot_attr,
509 509 link_tag=link_tag,
510 510 )
511 511 links_markup = self._range(link_map, self.radius)
512 512
513 513 # Replace ~...~ in token tmpl_format by range of pages
514 514 result = re.sub(r"~(\d+)~", links_markup, tmpl_format)
515 515
516 516 link_first = (
517 517 self.page > self.first_page and self.link_tag(link_map["first_page"]) or ""
518 518 )
519 519 link_last = (
520 520 self.page < self.last_page and self.link_tag(link_map["last_page"]) or ""
521 521 )
522 522 link_previous = (
523 523 self.previous_page and self.link_tag(link_map["previous_page"]) or ""
524 524 )
525 525 link_next = self.next_page and self.link_tag(link_map["next_page"]) or ""
526 526 # Interpolate '$' variables
527 527 result = Template(result).safe_substitute(
528 528 {
529 529 "first_page": self.first_page,
530 530 "last_page": self.last_page,
531 531 "page": self.page,
532 532 "page_count": self.page_count,
533 533 "items_per_page": self.items_per_page,
534 534 "first_item": self.first_item,
535 535 "last_item": self.last_item,
536 536 "item_count": self.item_count,
537 537 "link_first": link_first,
538 538 "link_last": link_last,
539 539 "link_previous": link_previous,
540 540 "link_next": link_next,
541 541 }
542 542 )
543 543
544 544 return result
545 545
546 546 def _get_edges(self, cur_page, max_page, items):
547 547 cur_page = int(cur_page)
548 548 edge = (items // 2) + 1
549 549 if cur_page <= edge:
550 550 radius = max(items // 2, items - cur_page)
551 551 elif (max_page - cur_page) < edge:
552 552 radius = (items - 1) - (max_page - cur_page)
553 553 else:
554 554 radius = (items // 2) - 1
555 555
556 556 left = max(1, (cur_page - radius))
557 557 right = min(max_page, cur_page + radius)
558 558 return left, right
559 559
560 560 def link_map(
561 561 self,
562 562 tmpl_format="~2~",
563 563 url=None,
564 564 show_if_single_page=False,
565 565 separator=" ",
566 566 symbol_first="&lt;&lt;",
567 567 symbol_last="&gt;&gt;",
568 568 symbol_previous="&lt;",
569 569 symbol_next="&gt;",
570 570 link_attr=None,
571 571 curpage_attr=None,
572 572 dotdot_attr=None,
573 573 link_tag=None
574 574 ):
575 575 """ Return map with links to other pages if default pager() function is not suitable solution.
576 576 tmpl_format:
577 577 Format string that defines how the pager would be normally rendered rendered. Uses same arguments as pager()
578 578 method, but returns a simple dictionary in form of:
579 579 {'current_page': {'attrs': {},
580 580 'href': 'http://example.org/foo/page=1',
581 581 'value': 1},
582 582 'first_page': {'attrs': {},
583 583 'href': 'http://example.org/foo/page=1',
584 584 'type': 'first_page',
585 585 'value': 1},
586 586 'last_page': {'attrs': {},
587 587 'href': 'http://example.org/foo/page=8',
588 588 'type': 'last_page',
589 589 'value': 8},
590 590 'next_page': {'attrs': {}, 'href': 'HREF', 'type': 'next_page', 'value': 2},
591 591 'previous_page': None,
592 592 'range_pages': [{'attrs': {},
593 593 'href': 'http://example.org/foo/page=1',
594 594 'type': 'current_page',
595 595 'value': 1},
596 596 ....
597 597 {'attrs': {}, 'href': '', 'type': 'span', 'value': '..'}]}
598 598
599 599
600 600 The string can contain the following $-tokens that are substituted by the
601 601 string.Template module:
602 602
603 603 - $first_page: number of first reachable page
604 604 - $last_page: number of last reachable page
605 605 - $page: number of currently selected page
606 606 - $page_count: number of reachable pages
607 607 - $items_per_page: maximal number of items per page
608 608 - $first_item: index of first item on the current page
609 609 - $last_item: index of last item on the current page
610 610 - $item_count: total number of items
611 611 - $link_first: link to first page (unless this is first page)
612 612 - $link_last: link to last page (unless this is last page)
613 613 - $link_previous: link to previous page (unless this is first page)
614 614 - $link_next: link to next page (unless this is last page)
615 615
616 616 To render a range of pages the token '~3~' can be used. The
617 617 number sets the radius of pages around the current page.
618 618 Example for a range with radius 3:
619 619
620 620 '1 .. 5 6 7 [8] 9 10 11 .. 50'
621 621
622 622 Default: '~2~'
623 623
624 624 url
625 625 The URL that page links will point to. Make sure it contains the string
626 626 $page which will be replaced by the actual page number.
627 627 Must be given unless a url_maker is specified to __init__, in which
628 628 case this parameter is ignored.
629 629
630 630 symbol_first
631 631 String to be displayed as the text for the $link_first link above.
632 632
633 633 Default: '&lt;&lt;' (<<)
634 634
635 635 symbol_last
636 636 String to be displayed as the text for the $link_last link above.
637 637
638 638 Default: '&gt;&gt;' (>>)
639 639
640 640 symbol_previous
641 641 String to be displayed as the text for the $link_previous link above.
642 642
643 643 Default: '&lt;' (<)
644 644
645 645 symbol_next
646 646 String to be displayed as the text for the $link_next link above.
647 647
648 648 Default: '&gt;' (>)
649 649
650 650 separator:
651 651 String that is used to separate page links/numbers in the above range of pages.
652 652
653 653 Default: ' '
654 654
655 655 show_if_single_page:
656 656 if True the navigator will be shown even if there is only one page.
657 657
658 658 Default: False
659 659
660 660 link_attr (optional)
661 661 A dictionary of attributes that get added to A-HREF links pointing to other pages. Can
662 662 be used to define a CSS style or class to customize the look of links.
663 663
664 664 Example: { 'style':'border: 1px solid green' }
665 665 Example: { 'class':'pager_link' }
666 666
667 667 curpage_attr (optional)
668 668 A dictionary of attributes that get added to the current page number in the pager (which
669 669 is obviously not a link). If this dictionary is not empty then the elements will be
670 670 wrapped in a SPAN tag with the given attributes.
671 671
672 672 Example: { 'style':'border: 3px solid blue' }
673 673 Example: { 'class':'pager_curpage' }
674 674
675 675 dotdot_attr (optional)
676 676 A dictionary of attributes that get added to the '..' string in the pager (which is
677 677 obviously not a link). If this dictionary is not empty then the elements will be wrapped
678 678 in a SPAN tag with the given attributes.
679 679
680 680 Example: { 'style':'color: #808080' }
681 681 Example: { 'class':'pager_dotdot' }
682 682 """
683 683 link_attr = link_attr or {}
684 684 curpage_attr = curpage_attr or {}
685 685 dotdot_attr = dotdot_attr or {}
686 686 self.curpage_attr = curpage_attr
687 687 self.separator = separator
688 688 self.link_attr = link_attr
689 689 self.dotdot_attr = dotdot_attr
690 690 self.url = url
691 691
692 692 regex_res = re.search(r"~(\d+)~", tmpl_format)
693 693 if regex_res:
694 694 radius = regex_res.group(1)
695 695 else:
696 696 radius = 2
697 697
698 698 self.radius = int(radius)
699 699
700 700 # Compute the first and last page number within the radius
701 701 # e.g. '1 .. 5 6 [7] 8 9 .. 12'
702 702 # -> leftmost_page = 5
703 703 # -> rightmost_page = 9
704 704 leftmost_page, rightmost_page = self._get_edges(
705 705 self.page, self.last_page, (self.radius * 2) + 1)
706 706
707 707 nav_items = {
708 708 "first_page": None,
709 709 "last_page": None,
710 710 "previous_page": None,
711 711 "next_page": None,
712 712 "current_page": None,
713 713 "radius": self.radius,
714 714 "range_pages": [],
715 715 }
716 716
717 717 if leftmost_page is None or rightmost_page is None:
718 718 return nav_items
719 719
720 720 nav_items["first_page"] = {
721 721 "type": "first_page",
722 722 "value": str(symbol_first),
723 723 "attrs": self.link_attr,
724 724 "number": self.first_page,
725 725 "href": self.url_maker(self.first_page),
726 726 }
727 727
728 728 # Insert dots if there are pages between the first page
729 729 # and the currently displayed page range
730 730 if leftmost_page - self.first_page > 1:
731 731 # Wrap in a SPAN tag if dotdot_attr is set
732 732 nav_items["range_pages"].append(
733 733 {
734 734 "type": "span",
735 735 "value": "..",
736 736 "attrs": self.dotdot_attr,
737 737 "href": "",
738 738 "number": None,
739 739 }
740 740 )
741 741
742 742 for this_page in range(leftmost_page, rightmost_page + 1):
743 743 # Highlight the current page number and do not use a link
744 744 if this_page == self.page:
745 745 # Wrap in a SPAN tag if curpage_attr is set
746 746 nav_items["range_pages"].append(
747 747 {
748 748 "type": "current_page",
749 749 "value": str(this_page),
750 750 "number": this_page,
751 751 "attrs": self.curpage_attr,
752 752 "href": self.url_maker(this_page),
753 753 }
754 754 )
755 755 nav_items["current_page"] = {
756 756 "value": this_page,
757 757 "attrs": self.curpage_attr,
758 758 "type": "current_page",
759 759 "href": self.url_maker(this_page),
760 760 }
761 761 # Otherwise create just a link to that page
762 762 else:
763 763 nav_items["range_pages"].append(
764 764 {
765 765 "type": "page",
766 766 "value": str(this_page),
767 767 "number": this_page,
768 768 "attrs": self.link_attr,
769 769 "href": self.url_maker(this_page),
770 770 }
771 771 )
772 772
773 773 # Insert dots if there are pages between the displayed
774 774 # page numbers and the end of the page range
775 775 if self.last_page - rightmost_page > 1:
776 776 # Wrap in a SPAN tag if dotdot_attr is set
777 777 nav_items["range_pages"].append(
778 778 {
779 779 "type": "span",
780 780 "value": "..",
781 781 "attrs": self.dotdot_attr,
782 782 "href": "",
783 783 "number": None,
784 784 }
785 785 )
786 786
787 787 # Create a link to the very last page (unless we are on the last
788 788 # page or there would be no need to insert '..' spacers)
789 789 nav_items["last_page"] = {
790 790 "type": "last_page",
791 791 "value": str(symbol_last),
792 792 "attrs": self.link_attr,
793 793 "href": self.url_maker(self.last_page),
794 794 "number": self.last_page,
795 795 }
796 796
797 797 nav_items["previous_page"] = {
798 798 "type": "previous_page",
799 799 "value": str(symbol_previous),
800 800 "attrs": self.link_attr,
801 801 "number": self.previous_page or self.first_page,
802 802 "href": self.url_maker(self.previous_page or self.first_page),
803 803 }
804 804
805 805 nav_items["next_page"] = {
806 806 "type": "next_page",
807 807 "value": str(symbol_next),
808 808 "attrs": self.link_attr,
809 809 "number": self.next_page or self.last_page,
810 810 "href": self.url_maker(self.next_page or self.last_page),
811 811 }
812 812
813 813 return nav_items
814 814
815 815 def _range(self, link_map, radius):
816 816 """
817 817 Return range of linked pages to substitute placeholder in pattern
818 818 """
819 819 # Compute the first and last page number within the radius
820 820 # e.g. '1 .. 5 6 [7] 8 9 .. 12'
821 821 # -> leftmost_page = 5
822 822 # -> rightmost_page = 9
823 823 leftmost_page, rightmost_page = self._get_edges(
824 824 self.page, self.last_page, (radius * 2) + 1)
825 825
826 826 nav_items = []
827 827 # Create a link to the first page (unless we are on the first page
828 828 # or there would be no need to insert '..' spacers)
829 829 if self.first_page and self.page != self.first_page and self.first_page < leftmost_page:
830 830 page = link_map["first_page"].copy()
831 831 page["value"] = str(page["number"])
832 832 nav_items.append(self.link_tag(page))
833 833
834 834 for item in link_map["range_pages"]:
835 835 nav_items.append(self.link_tag(item))
836 836
837 837 # Create a link to the very last page (unless we are on the last
838 838 # page or there would be no need to insert '..' spacers)
839 839 if self.last_page and self.page != self.last_page and rightmost_page < self.last_page:
840 840 page = link_map["last_page"].copy()
841 841 page["value"] = str(page["number"])
842 842 nav_items.append(self.link_tag(page))
843 843
844 844 return self.separator.join(nav_items)
845 845
846 846 def _default_url_maker(self, page_number):
847 847 if self.url is None:
848 848 raise Exception(
849 849 "You need to specify a 'url' parameter containing a '$page' placeholder."
850 850 )
851 851
852 852 if "$page" not in self.url:
853 853 raise Exception("The 'url' parameter must contain a '$page' placeholder.")
854 854
855 855 return self.url.replace("$page", str(page_number))
856 856
857 857 @staticmethod
858 858 def default_link_tag(item):
859 859 """
860 860 Create an A-HREF tag that points to another page.
861 861 """
862 862 text = item["value"]
863 863 target_url = item["href"]
864 864
865 865 if not item["href"] or item["type"] in ("span", "current_page"):
866 866 if item["attrs"]:
867 867 text = make_html_tag("span", **item["attrs"]) + text + "</span>"
868 868 return text
869 869
870 870 return make_html_tag("a", text=text, href=target_url, **item["attrs"])
871 871
872 872 # Below is RhodeCode custom code
873 873 # Copyright (C) 2010-2023 RhodeCode GmbH
874 874 #
875 875 # This program is free software: you can redistribute it and/or modify
876 876 # it under the terms of the GNU Affero General Public License, version 3
877 877 # (only), as published by the Free Software Foundation.
878 878 #
879 879 # This program is distributed in the hope that it will be useful,
880 880 # but WITHOUT ANY WARRANTY; without even the implied warranty of
881 881 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
882 882 # GNU General Public License for more details.
883 883 #
884 884 # You should have received a copy of the GNU Affero General Public License
885 885 # along with this program. If not, see <http://www.gnu.org/licenses/>.
886 886 #
887 887 # This program is dual-licensed. If you wish to learn more about the
888 888 # RhodeCode Enterprise Edition, including its added features, Support services,
889 889 # and proprietary license terms, please see https://rhodecode.com/licenses/
890 890
891 891
892 892 PAGE_FORMAT = '$link_previous ~3~ $link_next'
893 893
894 894
895 895 class SqlalchemyOrmWrapper(object):
896 896 """Wrapper class to access elements of a collection."""
897 897
898 898 def __init__(self, pager, collection):
899 899 self.pager = pager
900 900 self.collection = collection
901 901
902 902 def __getitem__(self, key):
903 903 if not isinstance(key, slice):
904 904 raise ValueError('Pagination without a slice not supported')
905 905
906 906 # Return a range of objects of an sqlalchemy.orm.query.Query object
907 907 return self.collection[key]
908 908
909 909 def __len__(self):
910 910 # support empty types, without actually making a query.
911 911 if self.collection is None or self.collection == []:
912 912 return 0
913 913
914 914 # Count the number of objects in an sqlalchemy.orm.query.Query object
915 915 return self.collection.count()
916 916
917 917
918 918 class CustomPager(_Page):
919 919
920 920 @staticmethod
921 921 def disabled_link_tag(item):
922 922 """
923 923 Create an A-HREF tag that is disabled
924 924 """
925 925 text = item['value']
926 926 attrs = item['attrs'].copy()
927 927 attrs['class'] = 'disabled ' + attrs['class']
928 928
929 929 return make_html_tag('a', text=text, **attrs)
930 930
931 931 def render(self):
932 932 # Don't show navigator if there is no more than one page
933 933 if self.page_count == 0:
934 934 return ""
935 935
936 936 self.link_tag = self.default_link_tag
937 937
938 938 link_map = self.link_map(
939 939 tmpl_format=PAGE_FORMAT, url=None,
940 940 show_if_single_page=False, separator=' ',
941 941 symbol_first='<<', symbol_last='>>',
942 942 symbol_previous='<', symbol_next='>',
943 943 link_attr={'class': 'pager_link'},
944 944 curpage_attr={'class': 'pager_curpage'},
945 945 dotdot_attr={'class': 'pager_dotdot'})
946 946
947 947 links_markup = self._range(link_map, self.radius)
948 948
949 949 link_first = (
950 950 self.page > self.first_page and self.link_tag(link_map['first_page']) or ''
951 951 )
952 952 link_last = (
953 953 self.page < self.last_page and self.link_tag(link_map['last_page']) or ''
954 954 )
955 955
956 956 link_previous = (
957 957 self.previous_page and self.link_tag(link_map['previous_page'])
958 958 or self.disabled_link_tag(link_map['previous_page'])
959 959 )
960 960 link_next = (
961 961 self.next_page and self.link_tag(link_map['next_page'])
962 962 or self.disabled_link_tag(link_map['next_page'])
963 963 )
964 964
965 965 # Interpolate '$' variables
966 966 # Replace ~...~ in token tmpl_format by range of pages
967 967 result = re.sub(r"~(\d+)~", links_markup, PAGE_FORMAT)
968 968 result = Template(result).safe_substitute(
969 969 {
970 970 "links": links_markup,
971 971 "first_page": self.first_page,
972 972 "last_page": self.last_page,
973 973 "page": self.page,
974 974 "page_count": self.page_count,
975 975 "items_per_page": self.items_per_page,
976 976 "first_item": self.first_item,
977 977 "last_item": self.last_item,
978 978 "item_count": self.item_count,
979 979 "link_first": link_first,
980 980 "link_last": link_last,
981 981 "link_previous": link_previous,
982 982 "link_next": link_next,
983 983 }
984 984 )
985 985
986 986 return literal(result)
987 987
988 988
989 989 class Page(CustomPager):
990 990 """
991 991 Custom pager to match rendering style with paginator
992 992 """
993 993
994 994 def __init__(self, collection, page=1, items_per_page=20, item_count=None,
995 995 url_maker=None, **kwargs):
996 996 """
997 Special type of pager. We intercept collection to wrap it in our custom
997 Special type of pager. We intercept a collection to wrap it in our custom
998 998 logic instead of using wrapper_class
999 999 """
1000 1000
1001 1001 super(Page, self).__init__(collection=collection, page=page,
1002 1002 items_per_page=items_per_page, item_count=item_count,
1003 1003 wrapper_class=None, url_maker=url_maker, **kwargs)
1004 1004
1005 1005
1006 1006 class SqlPage(CustomPager):
1007 1007 """
1008 1008 Custom pager to match rendering style with paginator
1009 1009 """
1010 1010
1011 1011 def __init__(self, collection, page=1, items_per_page=20, item_count=None,
1012 1012 url_maker=None, **kwargs):
1013 1013 """
1014 Special type of pager. We intercept collection to wrap it in our custom
1014 Special type of pager. We intercept a collection to wrap it in our custom
1015 1015 logic instead of using wrapper_class
1016 1016 """
1017 1017 collection = SqlalchemyOrmWrapper(self, collection)
1018 1018
1019 super(SqlPage, self).__init__(collection=collection, page=page,
1020 items_per_page=items_per_page, item_count=item_count,
1021 wrapper_class=None, url_maker=url_maker, **kwargs)
1019 super().__init__(
1020 collection=collection,
1021 page=page,
1022 items_per_page=items_per_page,
1023 item_count=item_count,
1024 wrapper_class=None,
1025 url_maker=url_maker,
1026 **kwargs
1027 )
1022 1028
1023 1029
1024 1030 class RepoCommitsWrapper(object):
1025 1031 """Wrapper class to access elements of a collection."""
1026 1032
1027 1033 def __init__(self, pager, collection):
1028 1034 self.pager = pager
1029 1035 self.collection = collection
1030 1036
1031 1037 def __getitem__(self, key):
1032 1038 cur_page = self.pager.page
1033 1039 items_per_page = self.pager.items_per_page
1034 1040 first_item = max(0, (len(self.collection) - (cur_page * items_per_page)))
1035 1041 last_item = ((len(self.collection) - 1) - items_per_page * (cur_page - 1))
1036 1042 return reversed(list(self.collection[first_item:last_item + 1]))
1037 1043
1038 1044 def __len__(self):
1039 1045 return len(self.collection)
1040 1046
1041 1047
1042 1048 class RepoPage(CustomPager):
1043 1049 """
1044 1050 Create a "RepoPage" instance. special pager for paging repository
1045 1051 """
1046 1052
1047 1053 def __init__(self, collection, page=1, items_per_page=20, item_count=None,
1048 1054 url_maker=None, **kwargs):
1049 1055 """
1050 1056 Special type of pager. We intercept collection to wrap it in our custom
1051 1057 logic instead of using wrapper_class
1052 1058 """
1053 1059 collection = RepoCommitsWrapper(self, collection)
1054 1060 super(RepoPage, self).__init__(collection=collection, page=page,
1055 1061 items_per_page=items_per_page, item_count=item_count,
1056 1062 wrapper_class=None, url_maker=url_maker, **kwargs)
@@ -1,455 +1,456 b''
1 1 # Copyright (C) 2011-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 """
21 21 Model for notifications
22 22 """
23 23
24 24 import logging
25 25 import traceback
26 26
27 27 import premailer
28 28 from pyramid.threadlocal import get_current_request
29 29 from sqlalchemy.sql.expression import false, true
30 30
31 31 import rhodecode
32 32 from rhodecode.lib import helpers as h
33 33 from rhodecode.model import BaseModel
34 34 from rhodecode.model.db import Notification, User, UserNotification
35 35 from rhodecode.model.meta import Session
36 36 from rhodecode.translation import TranslationString
37 37
38 38 log = logging.getLogger(__name__)
39 39
40 40
41 41 class NotificationModel(BaseModel):
42 42
43 43 cls = Notification
44 44
45 45 def __get_notification(self, notification):
46 46 if isinstance(notification, Notification):
47 47 return notification
48 48 elif isinstance(notification, int):
49 49 return Notification.get(notification)
50 50 else:
51 51 if notification:
52 52 raise Exception('notification must be int or Instance'
53 53 ' of Notification got %s' % type(notification))
54 54
55 55 def create(
56 56 self, created_by, notification_subject='', notification_body='',
57 57 notification_type=Notification.TYPE_MESSAGE, recipients=None,
58 58 mention_recipients=None, with_email=True, email_kwargs=None):
59 59 """
60 60
61 61 Creates notification of given type
62 62
63 63 :param created_by: int, str or User instance. User who created this
64 64 notification
65 65 :param notification_subject: subject of notification itself,
66 66 it will be generated automatically from notification_type if not specified
67 67 :param notification_body: body of notification text
68 68 it will be generated automatically from notification_type if not specified
69 69 :param notification_type: type of notification, based on that we
70 70 pick templates
71 71 :param recipients: list of int, str or User objects, when None
72 72 is given send to all admins
73 73 :param mention_recipients: list of int, str or User objects,
74 74 that were mentioned
75 75 :param with_email: send email with this notification
76 76 :param email_kwargs: dict with arguments to generate email
77 77 """
78 78
79 79 from rhodecode.lib.celerylib import tasks, run_task
80 80
81 81 if recipients and not getattr(recipients, '__iter__', False):
82 82 raise Exception('recipients must be an iterable object')
83 83
84 84 if not (notification_subject and notification_body) and not notification_type:
85 85 raise ValueError('notification_subject, and notification_body '
86 86 'cannot be empty when notification_type is not specified')
87 87
88 88 created_by_obj = self._get_user(created_by)
89 89
90 90 if not created_by_obj:
91 91 raise Exception('unknown user %s' % created_by)
92 92
93 93 # default MAIN body if not given
94 94 email_kwargs = email_kwargs or {'body': notification_body}
95 95 mention_recipients = mention_recipients or set()
96 96
97 97 if recipients is None:
98 98 # recipients is None means to all admins
99 99 recipients_objs = User.query().filter(User.admin == true()).all()
100 100 log.debug('sending notifications %s to admins: %s',
101 101 notification_type, recipients_objs)
102 102 else:
103 103 recipients_objs = set()
104 104 for u in recipients:
105 105 obj = self._get_user(u)
106 106 if obj:
107 107 recipients_objs.add(obj)
108 108 else: # we didn't find this user, log the error and carry on
109 109 log.error('cannot notify unknown user %r', u)
110 110
111 111 if not recipients_objs:
112 112 raise Exception('no valid recipients specified')
113 113
114 114 log.debug('sending notifications %s to %s',
115 115 notification_type, recipients_objs)
116 116
117 117 # add mentioned users into recipients
118 118 final_recipients = set(recipients_objs).union(mention_recipients)
119 119
120 120 (subject, email_body, email_body_plaintext) = \
121 121 EmailNotificationModel().render_email(notification_type, **email_kwargs)
122 122
123 123 if not notification_subject:
124 124 notification_subject = subject
125 125
126 126 if not notification_body:
127 127 notification_body = email_body_plaintext
128 128
129 129 notification = Notification.create(
130 130 created_by=created_by_obj, subject=notification_subject,
131 131 body=notification_body, recipients=final_recipients,
132 132 type_=notification_type
133 133 )
134 134
135 135 if not with_email: # skip sending email, and just create notification
136 136 return notification
137 137
138 138 # don't send email to person who created this comment
139 139 rec_objs = set(recipients_objs).difference({created_by_obj})
140 140
141 141 # now notify all recipients in question
142 142
143 143 for recipient in rec_objs.union(mention_recipients):
144 144 # inject current recipient
145 145 email_kwargs['recipient'] = recipient
146 146 email_kwargs['mention'] = recipient in mention_recipients
147 147 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
148 148 notification_type, **email_kwargs)
149 149
150 150 extra_headers = None
151 151 if 'thread_ids' in email_kwargs:
152 152 extra_headers = {'thread_ids': email_kwargs.pop('thread_ids')}
153 153
154 154 log.debug('Creating notification email task for user:`%s`', recipient)
155 155 task = run_task(tasks.send_email, recipient.email, subject,
156 156 email_body_plaintext, email_body, extra_headers=extra_headers)
157 157 log.debug('Created email task: %s', task)
158 158
159 159 return notification
160 160
161 161 def delete(self, user, notification):
162 162 # we don't want to remove actual notification just the assignment
163 163 try:
164 164 notification = self.__get_notification(notification)
165 165 user = self._get_user(user)
166 166 if notification and user:
167 167 obj = UserNotification.query()\
168 168 .filter(UserNotification.user == user)\
169 169 .filter(UserNotification.notification == notification)\
170 170 .one()
171 171 Session().delete(obj)
172 172 return True
173 173 except Exception:
174 174 log.error(traceback.format_exc())
175 175 raise
176 176
177 177 def get_for_user(self, user, filter_=None):
178 178 """
179 179 Get mentions for given user, filter them if filter dict is given
180 180 """
181 181 user = self._get_user(user)
182 182
183 183 q = UserNotification.query()\
184 184 .filter(UserNotification.user == user)\
185 185 .join((
186 186 Notification, UserNotification.notification_id ==
187 187 Notification.notification_id))
188 188 if filter_ == ['all']:
189 189 q = q # no filter
190 190 elif filter_ == ['unread']:
191 191 q = q.filter(UserNotification.read == false())
192 192 elif filter_:
193 193 q = q.filter(Notification.type_.in_(filter_))
194 194
195 q = q.order_by(Notification.created_on.desc())
195 196 return q
196 197
197 198 def mark_read(self, user, notification):
198 199 try:
199 200 notification = self.__get_notification(notification)
200 201 user = self._get_user(user)
201 202 if notification and user:
202 203 obj = UserNotification.query()\
203 204 .filter(UserNotification.user == user)\
204 205 .filter(UserNotification.notification == notification)\
205 206 .one()
206 207 obj.read = True
207 208 Session().add(obj)
208 209 return True
209 210 except Exception:
210 211 log.error(traceback.format_exc())
211 212 raise
212 213
213 214 def mark_all_read_for_user(self, user, filter_=None):
214 215 user = self._get_user(user)
215 216 q = UserNotification.query()\
216 217 .filter(UserNotification.user == user)\
217 218 .filter(UserNotification.read == false())\
218 219 .join((
219 220 Notification, UserNotification.notification_id ==
220 221 Notification.notification_id))
221 222 if filter_ == ['unread']:
222 223 q = q.filter(UserNotification.read == false())
223 224 elif filter_:
224 225 q = q.filter(Notification.type_.in_(filter_))
225 226
226 227 # this is a little inefficient but sqlalchemy doesn't support
227 228 # update on joined tables :(
228 229 for obj in q.all():
229 230 obj.read = True
230 231 Session().add(obj)
231 232
232 233 def get_unread_cnt_for_user(self, user):
233 234 user = self._get_user(user)
234 235 return UserNotification.query()\
235 236 .filter(UserNotification.read == false())\
236 237 .filter(UserNotification.user == user).count()
237 238
238 239 def get_unread_for_user(self, user):
239 240 user = self._get_user(user)
240 241 return [x.notification for x in UserNotification.query()
241 242 .filter(UserNotification.read == false())
242 243 .filter(UserNotification.user == user).all()]
243 244
244 245 def get_user_notification(self, user, notification):
245 246 user = self._get_user(user)
246 247 notification = self.__get_notification(notification)
247 248
248 249 return UserNotification.query()\
249 250 .filter(UserNotification.notification == notification)\
250 251 .filter(UserNotification.user == user).scalar()
251 252
252 253 def make_description(self, notification, translate, show_age=True):
253 254 """
254 255 Creates a human readable description based on properties
255 256 of notification object
256 257 """
257 258 _ = translate
258 259 _map = {
259 260 notification.TYPE_CHANGESET_COMMENT: [
260 261 _('%(user)s commented on commit %(date_or_age)s'),
261 262 _('%(user)s commented on commit at %(date_or_age)s'),
262 263 ],
263 264 notification.TYPE_MESSAGE: [
264 265 _('%(user)s sent message %(date_or_age)s'),
265 266 _('%(user)s sent message at %(date_or_age)s'),
266 267 ],
267 268 notification.TYPE_MENTION: [
268 269 _('%(user)s mentioned you %(date_or_age)s'),
269 270 _('%(user)s mentioned you at %(date_or_age)s'),
270 271 ],
271 272 notification.TYPE_REGISTRATION: [
272 273 _('%(user)s registered in RhodeCode %(date_or_age)s'),
273 274 _('%(user)s registered in RhodeCode at %(date_or_age)s'),
274 275 ],
275 276 notification.TYPE_PULL_REQUEST: [
276 277 _('%(user)s opened new pull request %(date_or_age)s'),
277 278 _('%(user)s opened new pull request at %(date_or_age)s'),
278 279 ],
279 280 notification.TYPE_PULL_REQUEST_UPDATE: [
280 281 _('%(user)s updated pull request %(date_or_age)s'),
281 282 _('%(user)s updated pull request at %(date_or_age)s'),
282 283 ],
283 284 notification.TYPE_PULL_REQUEST_COMMENT: [
284 285 _('%(user)s commented on pull request %(date_or_age)s'),
285 286 _('%(user)s commented on pull request at %(date_or_age)s'),
286 287 ],
287 288 }
288 289
289 290 templates = _map[notification.type_]
290 291
291 292 if show_age:
292 293 template = templates[0]
293 294 date_or_age = h.age(notification.created_on)
294 295 if translate:
295 296 date_or_age = translate(date_or_age)
296 297
297 298 if isinstance(date_or_age, TranslationString):
298 299 date_or_age = date_or_age.interpolate()
299 300
300 301 else:
301 302 template = templates[1]
302 303 date_or_age = h.format_date(notification.created_on)
303 304
304 305 return template % {
305 306 'user': notification.created_by_user.username,
306 307 'date_or_age': date_or_age,
307 308 }
308 309
309 310
310 311 # Templates for Titles, that could be overwritten by rcextensions
311 312 # Title of email for pull-request update
312 313 EMAIL_PR_UPDATE_SUBJECT_TEMPLATE = ''
313 314 # Title of email for request for pull request review
314 315 EMAIL_PR_REVIEW_SUBJECT_TEMPLATE = ''
315 316
316 317 # Title of email for general comment on pull request
317 318 EMAIL_PR_COMMENT_SUBJECT_TEMPLATE = ''
318 319 # Title of email for general comment which includes status change on pull request
319 320 EMAIL_PR_COMMENT_STATUS_CHANGE_SUBJECT_TEMPLATE = ''
320 321 # Title of email for inline comment on a file in pull request
321 322 EMAIL_PR_COMMENT_FILE_SUBJECT_TEMPLATE = ''
322 323
323 324 # Title of email for general comment on commit
324 325 EMAIL_COMMENT_SUBJECT_TEMPLATE = ''
325 326 # Title of email for general comment which includes status change on commit
326 327 EMAIL_COMMENT_STATUS_CHANGE_SUBJECT_TEMPLATE = ''
327 328 # Title of email for inline comment on a file in commit
328 329 EMAIL_COMMENT_FILE_SUBJECT_TEMPLATE = ''
329 330
330 331 import cssutils
331 332 # hijack css utils logger and replace with ours
332 333 log = logging.getLogger('rhodecode.cssutils.premailer')
333 334 log.setLevel(logging.INFO)
334 335 cssutils.log.setLog(log)
335 336
336 337
337 338 class EmailNotificationModel(BaseModel):
338 339 TYPE_COMMIT_COMMENT = Notification.TYPE_CHANGESET_COMMENT
339 340 TYPE_REGISTRATION = Notification.TYPE_REGISTRATION
340 341 TYPE_PULL_REQUEST = Notification.TYPE_PULL_REQUEST
341 342 TYPE_PULL_REQUEST_COMMENT = Notification.TYPE_PULL_REQUEST_COMMENT
342 343 TYPE_PULL_REQUEST_UPDATE = Notification.TYPE_PULL_REQUEST_UPDATE
343 344 TYPE_MAIN = Notification.TYPE_MESSAGE
344 345
345 346 TYPE_PASSWORD_RESET = 'password_reset'
346 347 TYPE_PASSWORD_RESET_CONFIRMATION = 'password_reset_confirmation'
347 348 TYPE_EMAIL_TEST = 'email_test'
348 349 TYPE_EMAIL_EXCEPTION = 'exception'
349 350 TYPE_UPDATE_AVAILABLE = 'update_available'
350 351 TYPE_TEST = 'test'
351 352
352 353 email_types = {
353 354 TYPE_MAIN:
354 355 'rhodecode:templates/email_templates/main.mako',
355 356 TYPE_TEST:
356 357 'rhodecode:templates/email_templates/test.mako',
357 358 TYPE_EMAIL_EXCEPTION:
358 359 'rhodecode:templates/email_templates/exception_tracker.mako',
359 360 TYPE_UPDATE_AVAILABLE:
360 361 'rhodecode:templates/email_templates/update_available.mako',
361 362 TYPE_EMAIL_TEST:
362 363 'rhodecode:templates/email_templates/email_test.mako',
363 364 TYPE_REGISTRATION:
364 365 'rhodecode:templates/email_templates/user_registration.mako',
365 366 TYPE_PASSWORD_RESET:
366 367 'rhodecode:templates/email_templates/password_reset.mako',
367 368 TYPE_PASSWORD_RESET_CONFIRMATION:
368 369 'rhodecode:templates/email_templates/password_reset_confirmation.mako',
369 370 TYPE_COMMIT_COMMENT:
370 371 'rhodecode:templates/email_templates/commit_comment.mako',
371 372 TYPE_PULL_REQUEST:
372 373 'rhodecode:templates/email_templates/pull_request_review.mako',
373 374 TYPE_PULL_REQUEST_COMMENT:
374 375 'rhodecode:templates/email_templates/pull_request_comment.mako',
375 376 TYPE_PULL_REQUEST_UPDATE:
376 377 'rhodecode:templates/email_templates/pull_request_update.mako',
377 378 }
378 379
379 380 premailer_instance = premailer.Premailer(
380 381 #cssutils_logging_handler=log.handlers[0],
381 382 #cssutils_logging_level=logging.INFO
382 383 )
383 384
384 385 def __init__(self):
385 386 """
386 387 Example usage::
387 388
388 389 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
389 390 EmailNotificationModel.TYPE_TEST, **email_kwargs)
390 391
391 392 """
392 393 super().__init__()
393 394 self.rhodecode_instance_name = rhodecode.CONFIG.get('rhodecode_title')
394 395
395 396 def _update_kwargs_for_render(self, kwargs):
396 397 """
397 398 Inject params required for Mako rendering
398 399
399 400 :param kwargs:
400 401 """
401 402
402 403 kwargs['rhodecode_instance_name'] = self.rhodecode_instance_name
403 404 kwargs['rhodecode_version'] = rhodecode.__version__
404 405 instance_url = h.route_url('home')
405 406 _kwargs = {
406 407 'instance_url': instance_url,
407 408 'whitespace_filter': self.whitespace_filter,
408 409 'email_pr_update_subject_template': EMAIL_PR_UPDATE_SUBJECT_TEMPLATE,
409 410 'email_pr_review_subject_template': EMAIL_PR_REVIEW_SUBJECT_TEMPLATE,
410 411 'email_pr_comment_subject_template': EMAIL_PR_COMMENT_SUBJECT_TEMPLATE,
411 412 'email_pr_comment_status_change_subject_template': EMAIL_PR_COMMENT_STATUS_CHANGE_SUBJECT_TEMPLATE,
412 413 'email_pr_comment_file_subject_template': EMAIL_PR_COMMENT_FILE_SUBJECT_TEMPLATE,
413 414 'email_comment_subject_template': EMAIL_COMMENT_SUBJECT_TEMPLATE,
414 415 'email_comment_status_change_subject_template': EMAIL_COMMENT_STATUS_CHANGE_SUBJECT_TEMPLATE,
415 416 'email_comment_file_subject_template': EMAIL_COMMENT_FILE_SUBJECT_TEMPLATE,
416 417 }
417 418 _kwargs.update(kwargs)
418 419 return _kwargs
419 420
420 421 def whitespace_filter(self, text):
421 422 return text.replace('\n', '').replace('\t', '')
422 423
423 424 def get_renderer(self, type_, request):
424 425 template_name = self.email_types[type_]
425 426 return request.get_partial_renderer(template_name)
426 427
427 428 def render_email(self, type_, **kwargs):
428 429 """
429 430 renders template for email, and returns a tuple of
430 431 (subject, email_headers, email_html_body, email_plaintext_body)
431 432 """
432 433 request = get_current_request()
433 434
434 435 # translator and helpers inject
435 436 _kwargs = self._update_kwargs_for_render(kwargs)
436 437 email_template = self.get_renderer(type_, request=request)
437 438 subject = email_template.render('subject', **_kwargs)
438 439
439 440 try:
440 441 body_plaintext = email_template.render('body_plaintext', **_kwargs)
441 442 except AttributeError:
442 443 # it's not defined in template, ok we can skip it
443 444 body_plaintext = ''
444 445
445 446 # render WHOLE template
446 447 body = email_template.render(None, **_kwargs)
447 448
448 449 try:
449 450 # Inline CSS styles and conversion
450 451 body = self.premailer_instance.transform(body)
451 452 except Exception:
452 453 log.exception('Failed to parse body with premailer')
453 454 pass
454 455
455 456 return subject, body, body_plaintext
@@ -1,310 +1,310 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 """
21 21 py.test config for test suite for making push/pull operations.
22 22
23 23 .. important::
24 24
25 25 You must have git >= 1.8.5 for tests to work fine. With 68b939b git started
26 26 to redirect things to stderr instead of stdout.
27 27 """
28 28
29 29 import os
30 30 import tempfile
31 31 import textwrap
32 32 import pytest
33 33 import logging
34 34 import requests
35 35
36 36 from rhodecode import events
37 37 from rhodecode.lib.str_utils import safe_bytes
38 38 from rhodecode.model.db import Integration, UserRepoToPerm, Permission, \
39 39 UserToRepoBranchPermission, User
40 40 from rhodecode.model.integration import IntegrationModel
41 41 from rhodecode.model.db import Repository
42 42 from rhodecode.model.meta import Session
43 43 from rhodecode.integrations.types.webhook import WebhookIntegrationType
44 44
45 45 from rhodecode.tests import GIT_REPO, HG_REPO
46 46 from rhodecode.tests.fixture import Fixture
47 47 from rhodecode.tests.server_utils import RcWebServer
48 48
49 49
50 50 REPO_GROUP = 'a_repo_group'
51 51 HG_REPO_WITH_GROUP = f'{REPO_GROUP}/{HG_REPO}'
52 52 GIT_REPO_WITH_GROUP = f'{REPO_GROUP}/{GIT_REPO}'
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56 # Docker image running httpbin...
57 HTTPBIN_DOMAIN = 'http://httpbin'
57 HTTPBIN_DOMAIN = 'http://httpbin:9090'
58 58 HTTPBIN_POST = HTTPBIN_DOMAIN + '/post'
59 59
60 60
61 61 def check_httpbin_connection():
62 62 try:
63 63 response = requests.get(HTTPBIN_DOMAIN)
64 64 return response.status_code == 200
65 65 except Exception as e:
66 66 print(e)
67 67
68 68 return False
69 69
70 70
71 71 @pytest.fixture(scope="module")
72 72 def rcextensions(request, db_connection, tmpdir_factory):
73 73 """
74 74 Installs a testing rcextensions pack to ensure they work as expected.
75 75 """
76 76 init_content = textwrap.dedent("""
77 77 # Forward import the example rcextensions to make it
78 78 # active for our tests.
79 79 from rhodecode.tests.other.example_rcextensions import *
80 80 """)
81 81
82 82 # Note: rcextensions are looked up based on the path of the ini file
83 83 root_path = tmpdir_factory.getbasetemp()
84 84 rcextensions_path = root_path.join('rcextensions')
85 85 init_path = rcextensions_path.join('__init__.py')
86 86
87 87 if rcextensions_path.check():
88 88 pytest.fail(
89 89 "Path for rcextensions already exists, please clean up before "
90 90 "test run this path: %s" % (rcextensions_path, ))
91 91 else:
92 92 request.addfinalizer(rcextensions_path.remove)
93 93 init_path.write_binary(safe_bytes(init_content), ensure=True)
94 94
95 95
96 96 @pytest.fixture(scope="module")
97 97 def repos(request, db_connection):
98 98 """Create a copy of each test repo in a repo group."""
99 99 fixture = Fixture()
100 100 repo_group = fixture.create_repo_group(REPO_GROUP)
101 101 repo_group_id = repo_group.group_id
102 102 fixture.create_fork(HG_REPO, HG_REPO,
103 103 repo_name_full=HG_REPO_WITH_GROUP,
104 104 repo_group=repo_group_id)
105 105 fixture.create_fork(GIT_REPO, GIT_REPO,
106 106 repo_name_full=GIT_REPO_WITH_GROUP,
107 107 repo_group=repo_group_id)
108 108
109 109 @request.addfinalizer
110 110 def cleanup():
111 111 fixture.destroy_repo(HG_REPO_WITH_GROUP)
112 112 fixture.destroy_repo(GIT_REPO_WITH_GROUP)
113 113 fixture.destroy_repo_group(repo_group_id)
114 114
115 115
116 116 @pytest.fixture(scope="module")
117 117 def rc_web_server_config_modification():
118 118 return []
119 119
120 120
121 121 @pytest.fixture(scope="module")
122 122 def rc_web_server_config_factory(testini_factory, rc_web_server_config_modification):
123 123 """
124 124 Configuration file used for the fixture `rc_web_server`.
125 125 """
126 126
127 127 def factory(rcweb_port, vcsserver_port):
128 128 custom_params = [
129 129 {'handler_console': {'level': 'DEBUG'}},
130 130 {'server:main': {'port': rcweb_port}},
131 131 {'app:main': {'vcs.server': 'localhost:%s' % vcsserver_port}}
132 132 ]
133 133 custom_params.extend(rc_web_server_config_modification)
134 134 return testini_factory(custom_params)
135 135 return factory
136 136
137 137
138 138 @pytest.fixture(scope="module")
139 139 def rc_web_server(
140 140 request, vcsserver_factory, available_port_factory,
141 141 rc_web_server_config_factory, repos, rcextensions):
142 142 """
143 143 Run the web server as a subprocess. with its own instance of vcsserver
144 144 """
145 145 rcweb_port = available_port_factory()
146 146 log.info('Using rcweb ops test port {}'.format(rcweb_port))
147 147
148 148 vcsserver_port = available_port_factory()
149 149 log.info('Using vcsserver ops test port {}'.format(vcsserver_port))
150 150
151 151 vcs_log = os.path.join(tempfile.gettempdir(), 'rc_op_vcs.log')
152 152 vcsserver_factory(
153 153 request, vcsserver_port=vcsserver_port,
154 154 log_file=vcs_log,
155 155 overrides=(
156 156 {'server:main': {'workers': 2}},
157 157 {'server:main': {'graceful_timeout': 10}},
158 158 ))
159 159
160 160 rc_log = os.path.join(tempfile.gettempdir(), 'rc_op_web.log')
161 161 rc_web_server_config = rc_web_server_config_factory(
162 162 rcweb_port=rcweb_port,
163 163 vcsserver_port=vcsserver_port)
164 164 server = RcWebServer(rc_web_server_config, log_file=rc_log)
165 165 server.start()
166 166
167 167 @request.addfinalizer
168 168 def cleanup():
169 169 server.shutdown()
170 170
171 171 server.wait_until_ready()
172 172 return server
173 173
174 174
175 175 @pytest.fixture()
176 176 def disable_locking(baseapp):
177 177 r = Repository.get_by_repo_name(GIT_REPO)
178 178 Repository.unlock(r)
179 179 r.enable_locking = False
180 180 Session().add(r)
181 181 Session().commit()
182 182
183 183 r = Repository.get_by_repo_name(HG_REPO)
184 184 Repository.unlock(r)
185 185 r.enable_locking = False
186 186 Session().add(r)
187 187 Session().commit()
188 188
189 189
190 190 @pytest.fixture()
191 191 def fs_repo_only(request, rhodecode_fixtures):
192 192 def fs_repo_fabric(repo_name, repo_type):
193 193 rhodecode_fixtures.create_repo(repo_name, repo_type=repo_type)
194 194 rhodecode_fixtures.destroy_repo(repo_name, fs_remove=False)
195 195
196 196 def cleanup():
197 197 rhodecode_fixtures.destroy_repo(repo_name, fs_remove=True)
198 198 rhodecode_fixtures.destroy_repo_on_filesystem(repo_name)
199 199
200 200 request.addfinalizer(cleanup)
201 201
202 202 return fs_repo_fabric
203 203
204 204
205 205 @pytest.fixture()
206 206 def enable_webhook_push_integration(request):
207 207 integration = Integration()
208 208 integration.integration_type = WebhookIntegrationType.key
209 209 Session().add(integration)
210 210
211 211 settings = dict(
212 212 url=HTTPBIN_POST,
213 213 secret_token='secret',
214 214 username=None,
215 215 password=None,
216 216 custom_header_key=None,
217 217 custom_header_val=None,
218 218 method_type='post',
219 219 events=[events.RepoPushEvent.name],
220 220 log_data=True
221 221 )
222 222
223 223 IntegrationModel().update_integration(
224 224 integration,
225 225 name='IntegrationWebhookTest',
226 226 enabled=True,
227 227 settings=settings,
228 228 repo=None,
229 229 repo_group=None,
230 230 child_repos_only=False,
231 231 )
232 232 Session().commit()
233 233 integration_id = integration.integration_id
234 234
235 235 @request.addfinalizer
236 236 def cleanup():
237 237 integration = Integration.get(integration_id)
238 238 Session().delete(integration)
239 239 Session().commit()
240 240
241 241
242 242 @pytest.fixture()
243 243 def branch_permission_setter(request):
244 244 """
245 245
246 246 def my_test(branch_permission_setter)
247 247 branch_permission_setter(repo_name, username, pattern='*', permission='branch.push')
248 248
249 249 """
250 250
251 251 rule_id = None
252 252 write_perm_id = None
253 253 write_perm = None
254 254 rule = None
255 255
256 256 def _branch_permissions_setter(
257 257 repo_name, username, pattern='*', permission='branch.push_force'):
258 258 global rule_id, write_perm_id
259 259 global rule, write_perm
260 260
261 261 repo = Repository.get_by_repo_name(repo_name)
262 262 repo_id = repo.repo_id
263 263
264 264 user = User.get_by_username(username)
265 265 user_id = user.user_id
266 266
267 267 rule_perm_obj = Permission.get_by_key(permission)
268 268
269 269 # add new entry, based on existing perm entry
270 270 perm = UserRepoToPerm.query() \
271 271 .filter(UserRepoToPerm.repository_id == repo_id) \
272 272 .filter(UserRepoToPerm.user_id == user_id) \
273 273 .first()
274 274
275 275 if not perm:
276 276 # such user isn't defined in Permissions for repository
277 277 # we now on-the-fly add new permission
278 278
279 279 write_perm = UserRepoToPerm()
280 280 write_perm.permission = Permission.get_by_key('repository.write')
281 281 write_perm.repository_id = repo_id
282 282 write_perm.user_id = user_id
283 283 Session().add(write_perm)
284 284 Session().flush()
285 285
286 286 perm = write_perm
287 287
288 288 rule = UserToRepoBranchPermission()
289 289 rule.rule_to_perm_id = perm.repo_to_perm_id
290 290 rule.branch_pattern = pattern
291 291 rule.rule_order = 10
292 292 rule.permission = rule_perm_obj
293 293 rule.repository_id = repo_id
294 294 Session().add(rule)
295 295 Session().commit()
296 296
297 297 return rule
298 298
299 299 @request.addfinalizer
300 300 def cleanup():
301 301 if rule:
302 302 Session().delete(rule)
303 303 Session().commit()
304 304 if write_perm:
305 305 Session().delete(write_perm)
306 306 Session().commit()
307 307
308 308 return _branch_permissions_setter
309 309
310 310
General Comments 0
You need to be logged in to leave comments. Login now