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