##// END OF EJS Templates
ruff: Test for membership should be `not in`
Mads Kiilerich -
r8763:cef93c7e default
parent child Browse files
Show More
@@ -1,673 +1,673 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
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 General Public License
12 # You should have received a copy of the GNU 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 kallithea.lib.webutils
15 kallithea.lib.webutils
16 ~~~~~~~~~~~~~~~~~~~~~~
16 ~~~~~~~~~~~~~~~~~~~~~~
17
17
18 Helper functions that may rely on the current WSGI request, exposed in the TG2
18 Helper functions that may rely on the current WSGI request, exposed in the TG2
19 thread-local "global" variables. It should have few dependencies so it can be
19 thread-local "global" variables. It should have few dependencies so it can be
20 imported anywhere - just like the global variables can be used everywhere.
20 imported anywhere - just like the global variables can be used everywhere.
21 """
21 """
22
22
23 import collections
23 import collections
24 import datetime
24 import datetime
25 import json
25 import json
26 import logging
26 import logging
27 import random
27 import random
28 import re
28 import re
29
29
30 from dateutil import relativedelta
30 from dateutil import relativedelta
31 from tg import request, session
31 from tg import request, session
32 from tg.i18n import ugettext as _
32 from tg.i18n import ugettext as _
33 from tg.i18n import ungettext
33 from tg.i18n import ungettext
34 from webhelpers2.html import HTML, escape, literal
34 from webhelpers2.html import HTML, escape, literal
35 from webhelpers2.html.tags import NotGiven, Option, Options, _input
35 from webhelpers2.html.tags import NotGiven, Option, Options, _input
36 from webhelpers2.html.tags import _make_safe_id_component as safeid
36 from webhelpers2.html.tags import _make_safe_id_component as safeid
37 from webhelpers2.html.tags import checkbox, end_form
37 from webhelpers2.html.tags import checkbox, end_form
38 from webhelpers2.html.tags import form as insecure_form
38 from webhelpers2.html.tags import form as insecure_form
39 from webhelpers2.html.tags import hidden, link_to, password, radio
39 from webhelpers2.html.tags import hidden, link_to, password, radio
40 from webhelpers2.html.tags import select as webhelpers2_select
40 from webhelpers2.html.tags import select as webhelpers2_select
41 from webhelpers2.html.tags import submit, text, textarea
41 from webhelpers2.html.tags import submit, text, textarea
42 from webhelpers2.number import format_byte_size
42 from webhelpers2.number import format_byte_size
43 from webhelpers2.text import chop_at, truncate, wrap_paragraphs
43 from webhelpers2.text import chop_at, truncate, wrap_paragraphs
44
44
45 import kallithea
45 import kallithea
46
46
47
47
48 log = logging.getLogger(__name__)
48 log = logging.getLogger(__name__)
49
49
50
50
51 # mute pyflakes "imported but unused"
51 # mute pyflakes "imported but unused"
52 assert Option
52 assert Option
53 assert checkbox
53 assert checkbox
54 assert chop_at
54 assert chop_at
55 assert end_form
55 assert end_form
56 assert escape
56 assert escape
57 assert format_byte_size
57 assert format_byte_size
58 assert link_to
58 assert link_to
59 assert literal
59 assert literal
60 assert password
60 assert password
61 assert radio
61 assert radio
62 assert safeid
62 assert safeid
63 assert submit
63 assert submit
64 assert text
64 assert text
65 assert textarea
65 assert textarea
66 assert truncate
66 assert truncate
67 assert wrap_paragraphs
67 assert wrap_paragraphs
68
68
69
69
70 # work around webhelpers2 being a dead project that doesn't support Python 3.10
70 # work around webhelpers2 being a dead project that doesn't support Python 3.10
71 collections.Sequence = collections.abc.Sequence
71 collections.Sequence = collections.abc.Sequence
72
72
73
73
74 #
74 #
75 # General Kallithea URL handling
75 # General Kallithea URL handling
76 #
76 #
77
77
78 class UrlGenerator(object):
78 class UrlGenerator(object):
79 """Emulate pylons.url in providing a wrapper around routes.url
79 """Emulate pylons.url in providing a wrapper around routes.url
80
80
81 This code was added during migration from Pylons to Turbogears2. Pylons
81 This code was added during migration from Pylons to Turbogears2. Pylons
82 already provided a wrapper like this, but Turbogears2 does not.
82 already provided a wrapper like this, but Turbogears2 does not.
83
83
84 When the routing of Kallithea is changed to use less Routes and more
84 When the routing of Kallithea is changed to use less Routes and more
85 Turbogears2-style routing, this class may disappear or change.
85 Turbogears2-style routing, this class may disappear or change.
86
86
87 url() (the __call__ method) returns the URL based on a route name and
87 url() (the __call__ method) returns the URL based on a route name and
88 arguments.
88 arguments.
89 url.current() returns the URL of the current page with arguments applied.
89 url.current() returns the URL of the current page with arguments applied.
90
90
91 Refer to documentation of Routes for details:
91 Refer to documentation of Routes for details:
92 https://routes.readthedocs.io/en/latest/generating.html#generation
92 https://routes.readthedocs.io/en/latest/generating.html#generation
93 """
93 """
94 def __call__(self, *args, **kwargs):
94 def __call__(self, *args, **kwargs):
95 return request.environ['routes.url'](*args, **kwargs)
95 return request.environ['routes.url'](*args, **kwargs)
96
96
97 def current(self, *args, **kwargs):
97 def current(self, *args, **kwargs):
98 return request.environ['routes.url'].current(*args, **kwargs)
98 return request.environ['routes.url'].current(*args, **kwargs)
99
99
100
100
101 url = UrlGenerator()
101 url = UrlGenerator()
102
102
103
103
104 def canonical_url(*args, **kargs):
104 def canonical_url(*args, **kargs):
105 '''Like url(x, qualified=True), but returns url that not only is qualified
105 '''Like url(x, qualified=True), but returns url that not only is qualified
106 but also canonical, as configured in canonical_url'''
106 but also canonical, as configured in canonical_url'''
107 try:
107 try:
108 parts = kallithea.CONFIG.get('canonical_url', '').split('://', 1)
108 parts = kallithea.CONFIG.get('canonical_url', '').split('://', 1)
109 kargs['host'] = parts[1]
109 kargs['host'] = parts[1]
110 kargs['protocol'] = parts[0]
110 kargs['protocol'] = parts[0]
111 except IndexError:
111 except IndexError:
112 kargs['qualified'] = True
112 kargs['qualified'] = True
113 return url(*args, **kargs)
113 return url(*args, **kargs)
114
114
115
115
116 def canonical_hostname():
116 def canonical_hostname():
117 '''Return canonical hostname of system'''
117 '''Return canonical hostname of system'''
118 try:
118 try:
119 parts = kallithea.CONFIG.get('canonical_url', '').split('://', 1)
119 parts = kallithea.CONFIG.get('canonical_url', '').split('://', 1)
120 return parts[1].split('/', 1)[0]
120 return parts[1].split('/', 1)[0]
121 except IndexError:
121 except IndexError:
122 parts = url('home', qualified=True).split('://', 1)
122 parts = url('home', qualified=True).split('://', 1)
123 return parts[1].split('/', 1)[0]
123 return parts[1].split('/', 1)[0]
124
124
125
125
126 #
126 #
127 # Custom Webhelpers2 stuff
127 # Custom Webhelpers2 stuff
128 #
128 #
129
129
130 def html_escape(s):
130 def html_escape(s):
131 """Return string with all html escaped.
131 """Return string with all html escaped.
132 This is also safe for javascript in html but not necessarily correct.
132 This is also safe for javascript in html but not necessarily correct.
133 """
133 """
134 return (s
134 return (s
135 .replace('&', '&amp;')
135 .replace('&', '&amp;')
136 .replace(">", "&gt;")
136 .replace(">", "&gt;")
137 .replace("<", "&lt;")
137 .replace("<", "&lt;")
138 .replace('"', "&quot;")
138 .replace('"', "&quot;")
139 .replace("'", "&apos;") # Note: this is HTML5 not HTML4 and might not work in mails
139 .replace("'", "&apos;") # Note: this is HTML5 not HTML4 and might not work in mails
140 )
140 )
141
141
142
142
143 def reset(name, value, id=NotGiven, **attrs):
143 def reset(name, value, id=NotGiven, **attrs):
144 """Create a reset button, similar to webhelpers2.html.tags.submit ."""
144 """Create a reset button, similar to webhelpers2.html.tags.submit ."""
145 return _input("reset", name, value, id, attrs)
145 return _input("reset", name, value, id, attrs)
146
146
147
147
148 def select(name, selected_values, options, id=NotGiven, **attrs):
148 def select(name, selected_values, options, id=NotGiven, **attrs):
149 """Convenient wrapper of webhelpers2 to let it accept options as a tuple list"""
149 """Convenient wrapper of webhelpers2 to let it accept options as a tuple list"""
150 if isinstance(options, list):
150 if isinstance(options, list):
151 option_list = options
151 option_list = options
152 # Handle old value,label lists ... where value also can be value,label lists
152 # Handle old value,label lists ... where value also can be value,label lists
153 options = Options()
153 options = Options()
154 for x in option_list:
154 for x in option_list:
155 if isinstance(x, tuple) and len(x) == 2:
155 if isinstance(x, tuple) and len(x) == 2:
156 value, label = x
156 value, label = x
157 elif isinstance(x, str):
157 elif isinstance(x, str):
158 value = label = x
158 value = label = x
159 else:
159 else:
160 log.error('invalid select option %r', x)
160 log.error('invalid select option %r', x)
161 raise
161 raise
162 if isinstance(value, list):
162 if isinstance(value, list):
163 og = options.add_optgroup(label)
163 og = options.add_optgroup(label)
164 for x in value:
164 for x in value:
165 if isinstance(x, tuple) and len(x) == 2:
165 if isinstance(x, tuple) and len(x) == 2:
166 group_value, group_label = x
166 group_value, group_label = x
167 elif isinstance(x, str):
167 elif isinstance(x, str):
168 group_value = group_label = x
168 group_value = group_label = x
169 else:
169 else:
170 log.error('invalid select option %r', x)
170 log.error('invalid select option %r', x)
171 raise
171 raise
172 og.add_option(group_label, group_value)
172 og.add_option(group_label, group_value)
173 else:
173 else:
174 options.add_option(label, value)
174 options.add_option(label, value)
175 return webhelpers2_select(name, selected_values, options, id=id, **attrs)
175 return webhelpers2_select(name, selected_values, options, id=id, **attrs)
176
176
177
177
178 session_csrf_secret_name = "_session_csrf_secret_token"
178 session_csrf_secret_name = "_session_csrf_secret_token"
179
179
180 def session_csrf_secret_token():
180 def session_csrf_secret_token():
181 """Return (and create) the current session's CSRF protection token."""
181 """Return (and create) the current session's CSRF protection token."""
182 if not session_csrf_secret_name in session:
182 if session_csrf_secret_name not in session:
183 session[session_csrf_secret_name] = str(random.getrandbits(128))
183 session[session_csrf_secret_name] = str(random.getrandbits(128))
184 session.save()
184 session.save()
185 return session[session_csrf_secret_name]
185 return session[session_csrf_secret_name]
186
186
187 def form(url, method="post", **attrs):
187 def form(url, method="post", **attrs):
188 """Like webhelpers.html.tags.form , but automatically adding
188 """Like webhelpers.html.tags.form , but automatically adding
189 session_csrf_secret_token for POST. The secret is thus never leaked in GET
189 session_csrf_secret_token for POST. The secret is thus never leaked in GET
190 URLs.
190 URLs.
191 """
191 """
192 form = insecure_form(url, method, **attrs)
192 form = insecure_form(url, method, **attrs)
193 if method.lower() == 'get':
193 if method.lower() == 'get':
194 return form
194 return form
195 return form + HTML.div(hidden(session_csrf_secret_name, session_csrf_secret_token()), style="display: none;")
195 return form + HTML.div(hidden(session_csrf_secret_name, session_csrf_secret_token()), style="display: none;")
196
196
197
197
198 #
198 #
199 # Flash messages, stored in cookie
199 # Flash messages, stored in cookie
200 #
200 #
201
201
202 class _Message(object):
202 class _Message(object):
203 """A message returned by ``pop_flash_messages()``.
203 """A message returned by ``pop_flash_messages()``.
204
204
205 Converting the message to a string returns the message text. Instances
205 Converting the message to a string returns the message text. Instances
206 also have the following attributes:
206 also have the following attributes:
207
207
208 * ``category``: the category specified when the message was created.
208 * ``category``: the category specified when the message was created.
209 * ``message``: the html-safe message text.
209 * ``message``: the html-safe message text.
210 """
210 """
211
211
212 def __init__(self, category, message):
212 def __init__(self, category, message):
213 self.category = category
213 self.category = category
214 self.message = message
214 self.message = message
215
215
216
216
217 def _session_flash_messages(append=None, clear=False):
217 def _session_flash_messages(append=None, clear=False):
218 """Manage a message queue in tg.session: return the current message queue
218 """Manage a message queue in tg.session: return the current message queue
219 after appending the given message, and possibly clearing the queue."""
219 after appending the given message, and possibly clearing the queue."""
220 key = 'flash'
220 key = 'flash'
221 if key in session:
221 if key in session:
222 flash_messages = session[key]
222 flash_messages = session[key]
223 else:
223 else:
224 if append is None: # common fast path - also used for clearing empty queue
224 if append is None: # common fast path - also used for clearing empty queue
225 return [] # don't bother saving
225 return [] # don't bother saving
226 flash_messages = []
226 flash_messages = []
227 session[key] = flash_messages
227 session[key] = flash_messages
228 if append is not None and append not in flash_messages:
228 if append is not None and append not in flash_messages:
229 flash_messages.append(append)
229 flash_messages.append(append)
230 if clear:
230 if clear:
231 session.pop(key, None)
231 session.pop(key, None)
232 session.save()
232 session.save()
233 return flash_messages
233 return flash_messages
234
234
235
235
236 def flash(message, category, logf=None):
236 def flash(message, category, logf=None):
237 """
237 """
238 Show a message to the user _and_ log it through the specified function
238 Show a message to the user _and_ log it through the specified function
239
239
240 category: notice (default), warning, error, success
240 category: notice (default), warning, error, success
241 logf: a custom log function - such as log.debug
241 logf: a custom log function - such as log.debug
242
242
243 logf defaults to log.info, unless category equals 'success', in which
243 logf defaults to log.info, unless category equals 'success', in which
244 case logf defaults to log.debug.
244 case logf defaults to log.debug.
245 """
245 """
246 assert category in ('error', 'success', 'warning'), category
246 assert category in ('error', 'success', 'warning'), category
247 if hasattr(message, '__html__'):
247 if hasattr(message, '__html__'):
248 # render to HTML for storing in cookie
248 # render to HTML for storing in cookie
249 safe_message = str(message)
249 safe_message = str(message)
250 else:
250 else:
251 # Apply str - the message might be an exception with __str__
251 # Apply str - the message might be an exception with __str__
252 # Escape, so we can trust the result without further escaping, without any risk of injection
252 # Escape, so we can trust the result without further escaping, without any risk of injection
253 safe_message = html_escape(str(message))
253 safe_message = html_escape(str(message))
254 if logf is None:
254 if logf is None:
255 logf = log.info
255 logf = log.info
256 if category == 'success':
256 if category == 'success':
257 logf = log.debug
257 logf = log.debug
258
258
259 logf('Flash %s: %s', category, safe_message)
259 logf('Flash %s: %s', category, safe_message)
260
260
261 _session_flash_messages(append=(category, safe_message))
261 _session_flash_messages(append=(category, safe_message))
262
262
263
263
264 def pop_flash_messages():
264 def pop_flash_messages():
265 """Return all accumulated messages and delete them from the session.
265 """Return all accumulated messages and delete them from the session.
266
266
267 The return value is a list of ``Message`` objects.
267 The return value is a list of ``Message`` objects.
268 """
268 """
269 return [_Message(category, message) for category, message in _session_flash_messages(clear=True)]
269 return [_Message(category, message) for category, message in _session_flash_messages(clear=True)]
270
270
271
271
272 #
272 #
273 # Generic-ish formatting and markup
273 # Generic-ish formatting and markup
274 #
274 #
275
275
276 def js(value):
276 def js(value):
277 """Convert Python value to the corresponding JavaScript representation.
277 """Convert Python value to the corresponding JavaScript representation.
278
278
279 This is necessary to safely insert arbitrary values into HTML <script>
279 This is necessary to safely insert arbitrary values into HTML <script>
280 sections e.g. using Mako template expression substitution.
280 sections e.g. using Mako template expression substitution.
281
281
282 Note: Rather than using this function, it's preferable to avoid the
282 Note: Rather than using this function, it's preferable to avoid the
283 insertion of values into HTML <script> sections altogether. Instead,
283 insertion of values into HTML <script> sections altogether. Instead,
284 data should (to the extent possible) be passed to JavaScript using
284 data should (to the extent possible) be passed to JavaScript using
285 data attributes or AJAX calls, eliminating the need for JS specific
285 data attributes or AJAX calls, eliminating the need for JS specific
286 escaping.
286 escaping.
287
287
288 Note: This is not safe for use in attributes (e.g. onclick), because
288 Note: This is not safe for use in attributes (e.g. onclick), because
289 quotes are not escaped.
289 quotes are not escaped.
290
290
291 Because the rules for parsing <script> varies between XHTML (where
291 Because the rules for parsing <script> varies between XHTML (where
292 normal rules apply for any special characters) and HTML (where
292 normal rules apply for any special characters) and HTML (where
293 entities are not interpreted, but the literal string "</script>"
293 entities are not interpreted, but the literal string "</script>"
294 is forbidden), the function ensures that the result never contains
294 is forbidden), the function ensures that the result never contains
295 '&', '<' and '>', thus making it safe in both those contexts (but
295 '&', '<' and '>', thus making it safe in both those contexts (but
296 not in attributes).
296 not in attributes).
297 """
297 """
298 return literal(
298 return literal(
299 ('(' + json.dumps(value) + ')')
299 ('(' + json.dumps(value) + ')')
300 # In JSON, the following can only appear in string literals.
300 # In JSON, the following can only appear in string literals.
301 .replace('&', r'\x26')
301 .replace('&', r'\x26')
302 .replace('<', r'\x3c')
302 .replace('<', r'\x3c')
303 .replace('>', r'\x3e')
303 .replace('>', r'\x3e')
304 )
304 )
305
305
306
306
307 def jshtml(val):
307 def jshtml(val):
308 """HTML escapes a string value, then converts the resulting string
308 """HTML escapes a string value, then converts the resulting string
309 to its corresponding JavaScript representation (see `js`).
309 to its corresponding JavaScript representation (see `js`).
310
310
311 This is used when a plain-text string (possibly containing special
311 This is used when a plain-text string (possibly containing special
312 HTML characters) will be used by a script in an HTML context (e.g.
312 HTML characters) will be used by a script in an HTML context (e.g.
313 element.innerHTML or jQuery's 'html' method).
313 element.innerHTML or jQuery's 'html' method).
314
314
315 If in doubt, err on the side of using `jshtml` over `js`, since it's
315 If in doubt, err on the side of using `jshtml` over `js`, since it's
316 better to escape too much than too little.
316 better to escape too much than too little.
317 """
317 """
318 return js(escape(val))
318 return js(escape(val))
319
319
320
320
321 url_re = re.compile(r'''\bhttps?://(?:[\da-zA-Z0-9@:.-]+)'''
321 url_re = re.compile(r'''\bhttps?://(?:[\da-zA-Z0-9@:.-]+)'''
322 r'''(?:[/a-zA-Z0-9_=@#~&+%.,:;?!*()-]*[/a-zA-Z0-9_=@#~])?''')
322 r'''(?:[/a-zA-Z0-9_=@#~&+%.,:;?!*()-]*[/a-zA-Z0-9_=@#~])?''')
323
323
324
324
325 # Must match regexp in kallithea/public/js/base.js MentionsAutoComplete()
325 # Must match regexp in kallithea/public/js/base.js MentionsAutoComplete()
326 # Check char before @ - it must not look like we are in an email addresses.
326 # Check char before @ - it must not look like we are in an email addresses.
327 # Matching is greedy so we don't have to look beyond the end.
327 # Matching is greedy so we don't have to look beyond the end.
328 MENTIONS_REGEX = re.compile(r'(?:^|(?<=[^a-zA-Z0-9]))@([a-zA-Z0-9][-_.a-zA-Z0-9]*[a-zA-Z0-9])')
328 MENTIONS_REGEX = re.compile(r'(?:^|(?<=[^a-zA-Z0-9]))@([a-zA-Z0-9][-_.a-zA-Z0-9]*[a-zA-Z0-9])')
329
329
330
330
331 def extract_mentioned_usernames(text):
331 def extract_mentioned_usernames(text):
332 r"""
332 r"""
333 Returns list of (possible) usernames @mentioned in given text.
333 Returns list of (possible) usernames @mentioned in given text.
334
334
335 >>> extract_mentioned_usernames('@1-2.a_X,@1234 not@not @ddd@not @n @ee @ff @gg, @gg;@hh @n\n@zz,')
335 >>> extract_mentioned_usernames('@1-2.a_X,@1234 not@not @ddd@not @n @ee @ff @gg, @gg;@hh @n\n@zz,')
336 ['1-2.a_X', '1234', 'ddd', 'ee', 'ff', 'gg', 'gg', 'hh', 'zz']
336 ['1-2.a_X', '1234', 'ddd', 'ee', 'ff', 'gg', 'gg', 'hh', 'zz']
337 """
337 """
338 return MENTIONS_REGEX.findall(text)
338 return MENTIONS_REGEX.findall(text)
339
339
340
340
341 _URLIFY_RE = re.compile(r'''
341 _URLIFY_RE = re.compile(r'''
342 # URL markup
342 # URL markup
343 (?P<url>%s) |
343 (?P<url>%s) |
344 # @mention markup
344 # @mention markup
345 (?P<mention>%s) |
345 (?P<mention>%s) |
346 # Changeset hash markup
346 # Changeset hash markup
347 (?<!\w|[-_])
347 (?<!\w|[-_])
348 (?P<hash>[0-9a-f]{12,40})
348 (?P<hash>[0-9a-f]{12,40})
349 (?!\w|[-_]) |
349 (?!\w|[-_]) |
350 # Markup of *bold text*
350 # Markup of *bold text*
351 (?:
351 (?:
352 (?:^|(?<=\s))
352 (?:^|(?<=\s))
353 (?P<bold> [*] (?!\s) [^*\n]* (?<!\s) [*] )
353 (?P<bold> [*] (?!\s) [^*\n]* (?<!\s) [*] )
354 (?![*\w])
354 (?![*\w])
355 ) |
355 ) |
356 # "Stylize" markup
356 # "Stylize" markup
357 \[see\ \=&gt;\ *(?P<seen>[a-zA-Z0-9\/\=\?\&\ \:\/\.\-]*)\] |
357 \[see\ \=&gt;\ *(?P<seen>[a-zA-Z0-9\/\=\?\&\ \:\/\.\-]*)\] |
358 \[license\ \=&gt;\ *(?P<license>[a-zA-Z0-9\/\=\?\&\ \:\/\.\-]*)\] |
358 \[license\ \=&gt;\ *(?P<license>[a-zA-Z0-9\/\=\?\&\ \:\/\.\-]*)\] |
359 \[(?P<tagtype>requires|recommends|conflicts|base)\ \=&gt;\ *(?P<tagvalue>[a-zA-Z0-9\-\/]*)\] |
359 \[(?P<tagtype>requires|recommends|conflicts|base)\ \=&gt;\ *(?P<tagvalue>[a-zA-Z0-9\-\/]*)\] |
360 \[(?:lang|language)\ \=&gt;\ *(?P<lang>[a-zA-Z\-\/\#\+]*)\] |
360 \[(?:lang|language)\ \=&gt;\ *(?P<lang>[a-zA-Z\-\/\#\+]*)\] |
361 \[(?P<tag>[a-z]+)\]
361 \[(?P<tag>[a-z]+)\]
362 ''' % (url_re.pattern, MENTIONS_REGEX.pattern),
362 ''' % (url_re.pattern, MENTIONS_REGEX.pattern),
363 re.VERBOSE | re.MULTILINE | re.IGNORECASE)
363 re.VERBOSE | re.MULTILINE | re.IGNORECASE)
364
364
365
365
366 def urlify_text(s, repo_name=None, link_=None, truncate=None, stylize=False, truncatef=truncate):
366 def urlify_text(s, repo_name=None, link_=None, truncate=None, stylize=False, truncatef=truncate):
367 """
367 """
368 Parses given text message and make literal html with markup.
368 Parses given text message and make literal html with markup.
369 The text will be truncated to the specified length.
369 The text will be truncated to the specified length.
370 Hashes are turned into changeset links to specified repository.
370 Hashes are turned into changeset links to specified repository.
371 URLs links to what they say.
371 URLs links to what they say.
372 Issues are linked to given issue-server.
372 Issues are linked to given issue-server.
373 If link_ is provided, all text not already linking somewhere will link there.
373 If link_ is provided, all text not already linking somewhere will link there.
374 >>> urlify_text("Urlify http://example.com/ and 'https://example.com' *and* <b>markup/b>")
374 >>> urlify_text("Urlify http://example.com/ and 'https://example.com' *and* <b>markup/b>")
375 literal('Urlify <a href="http://example.com/">http://example.com/</a> and &#39;<a href="https://example.com&apos">https://example.com&apos</a>; <b>*and*</b> &lt;b&gt;markup/b&gt;')
375 literal('Urlify <a href="http://example.com/">http://example.com/</a> and &#39;<a href="https://example.com&apos">https://example.com&apos</a>; <b>*and*</b> &lt;b&gt;markup/b&gt;')
376 """
376 """
377
377
378 def _replace(match_obj):
378 def _replace(match_obj):
379 match_url = match_obj.group('url')
379 match_url = match_obj.group('url')
380 if match_url is not None:
380 if match_url is not None:
381 return '<a href="%(url)s">%(url)s</a>' % {'url': match_url}
381 return '<a href="%(url)s">%(url)s</a>' % {'url': match_url}
382 mention = match_obj.group('mention')
382 mention = match_obj.group('mention')
383 if mention is not None:
383 if mention is not None:
384 return '<b>%s</b>' % mention
384 return '<b>%s</b>' % mention
385 hash_ = match_obj.group('hash')
385 hash_ = match_obj.group('hash')
386 if hash_ is not None and repo_name is not None:
386 if hash_ is not None and repo_name is not None:
387 return '<a class="changeset_hash" href="%(url)s">%(hash)s</a>' % {
387 return '<a class="changeset_hash" href="%(url)s">%(hash)s</a>' % {
388 'url': url('changeset_home', repo_name=repo_name, revision=hash_),
388 'url': url('changeset_home', repo_name=repo_name, revision=hash_),
389 'hash': hash_,
389 'hash': hash_,
390 }
390 }
391 bold = match_obj.group('bold')
391 bold = match_obj.group('bold')
392 if bold is not None:
392 if bold is not None:
393 return '<b>*%s*</b>' % _urlify(bold[1:-1])
393 return '<b>*%s*</b>' % _urlify(bold[1:-1])
394 if stylize:
394 if stylize:
395 seen = match_obj.group('seen')
395 seen = match_obj.group('seen')
396 if seen:
396 if seen:
397 return '<div class="label label-meta" data-tag="see">see =&gt; %s</div>' % seen
397 return '<div class="label label-meta" data-tag="see">see =&gt; %s</div>' % seen
398 license = match_obj.group('license')
398 license = match_obj.group('license')
399 if license:
399 if license:
400 return '<div class="label label-meta" data-tag="license"><a href="http://www.opensource.org/licenses/%s">%s</a></div>' % (license, license)
400 return '<div class="label label-meta" data-tag="license"><a href="http://www.opensource.org/licenses/%s">%s</a></div>' % (license, license)
401 tagtype = match_obj.group('tagtype')
401 tagtype = match_obj.group('tagtype')
402 if tagtype:
402 if tagtype:
403 tagvalue = match_obj.group('tagvalue')
403 tagvalue = match_obj.group('tagvalue')
404 return '<div class="label label-meta" data-tag="%s">%s =&gt; <a href="/%s">%s</a></div>' % (tagtype, tagtype, tagvalue, tagvalue)
404 return '<div class="label label-meta" data-tag="%s">%s =&gt; <a href="/%s">%s</a></div>' % (tagtype, tagtype, tagvalue, tagvalue)
405 lang = match_obj.group('lang')
405 lang = match_obj.group('lang')
406 if lang:
406 if lang:
407 return '<div class="label label-meta" data-tag="lang">%s</div>' % lang
407 return '<div class="label label-meta" data-tag="lang">%s</div>' % lang
408 tag = match_obj.group('tag')
408 tag = match_obj.group('tag')
409 if tag:
409 if tag:
410 return '<div class="label label-meta" data-tag="%s">%s</div>' % (tag, tag)
410 return '<div class="label label-meta" data-tag="%s">%s</div>' % (tag, tag)
411 return match_obj.group(0)
411 return match_obj.group(0)
412
412
413 def _urlify(s):
413 def _urlify(s):
414 """
414 """
415 Extract urls from text and make html links out of them
415 Extract urls from text and make html links out of them
416 """
416 """
417 return _URLIFY_RE.sub(_replace, s)
417 return _URLIFY_RE.sub(_replace, s)
418
418
419 if truncate is None:
419 if truncate is None:
420 s = s.rstrip()
420 s = s.rstrip()
421 else:
421 else:
422 s = truncatef(s, truncate, whole_word=True)
422 s = truncatef(s, truncate, whole_word=True)
423 s = html_escape(s)
423 s = html_escape(s)
424 s = _urlify(s)
424 s = _urlify(s)
425 if repo_name is not None:
425 if repo_name is not None:
426 s = _urlify_issues(s, repo_name)
426 s = _urlify_issues(s, repo_name)
427 if link_ is not None:
427 if link_ is not None:
428 # make href around everything that isn't a href already
428 # make href around everything that isn't a href already
429 s = _linkify_others(s, link_)
429 s = _linkify_others(s, link_)
430 s = s.replace('\r\n', '<br/>').replace('\n', '<br/>')
430 s = s.replace('\r\n', '<br/>').replace('\n', '<br/>')
431 # Turn HTML5 into more valid HTML4 as required by some mail readers.
431 # Turn HTML5 into more valid HTML4 as required by some mail readers.
432 # (This is not done in one step in html_escape, because character codes like
432 # (This is not done in one step in html_escape, because character codes like
433 # &#123; risk to be seen as an issue reference due to the presence of '#'.)
433 # &#123; risk to be seen as an issue reference due to the presence of '#'.)
434 s = s.replace("&apos;", "&#39;")
434 s = s.replace("&apos;", "&#39;")
435 return literal(s)
435 return literal(s)
436
436
437
437
438 def _linkify_others(t, l):
438 def _linkify_others(t, l):
439 """Add a default link to html with links.
439 """Add a default link to html with links.
440 HTML doesn't allow nesting of links, so the outer link must be broken up
440 HTML doesn't allow nesting of links, so the outer link must be broken up
441 in pieces and give space for other links.
441 in pieces and give space for other links.
442 """
442 """
443 urls = re.compile(r'(\<a.*?\<\/a\>)',)
443 urls = re.compile(r'(\<a.*?\<\/a\>)',)
444 links = []
444 links = []
445 for e in urls.split(t):
445 for e in urls.split(t):
446 if e.strip() and not urls.match(e):
446 if e.strip() and not urls.match(e):
447 links.append('<a class="message-link" href="%s">%s</a>' % (l, e))
447 links.append('<a class="message-link" href="%s">%s</a>' % (l, e))
448 else:
448 else:
449 links.append(e)
449 links.append(e)
450 return ''.join(links)
450 return ''.join(links)
451
451
452
452
453 # Global variable that will hold the actual _urlify_issues function body.
453 # Global variable that will hold the actual _urlify_issues function body.
454 # Will be set on first use when the global configuration has been read.
454 # Will be set on first use when the global configuration has been read.
455 _urlify_issues_f = None
455 _urlify_issues_f = None
456
456
457
457
458 def _urlify_issues(newtext, repo_name):
458 def _urlify_issues(newtext, repo_name):
459 """Urlify issue references according to .ini configuration"""
459 """Urlify issue references according to .ini configuration"""
460 global _urlify_issues_f
460 global _urlify_issues_f
461 if _urlify_issues_f is None:
461 if _urlify_issues_f is None:
462 assert kallithea.CONFIG['sqlalchemy.url'] # make sure config has been loaded
462 assert kallithea.CONFIG['sqlalchemy.url'] # make sure config has been loaded
463
463
464 # Build chain of urlify functions, starting with not doing any transformation
464 # Build chain of urlify functions, starting with not doing any transformation
465 def tmp_urlify_issues_f(s):
465 def tmp_urlify_issues_f(s):
466 return s
466 return s
467
467
468 issue_pat_re = re.compile(r'issue_pat(.*)')
468 issue_pat_re = re.compile(r'issue_pat(.*)')
469 for k in kallithea.CONFIG:
469 for k in kallithea.CONFIG:
470 # Find all issue_pat* settings that also have corresponding server_link and prefix configuration
470 # Find all issue_pat* settings that also have corresponding server_link and prefix configuration
471 m = issue_pat_re.match(k)
471 m = issue_pat_re.match(k)
472 if m is None:
472 if m is None:
473 continue
473 continue
474 suffix = m.group(1)
474 suffix = m.group(1)
475 issue_pat = kallithea.CONFIG.get(k)
475 issue_pat = kallithea.CONFIG.get(k)
476 issue_server_link = kallithea.CONFIG.get('issue_server_link%s' % suffix)
476 issue_server_link = kallithea.CONFIG.get('issue_server_link%s' % suffix)
477 issue_sub = kallithea.CONFIG.get('issue_sub%s' % suffix)
477 issue_sub = kallithea.CONFIG.get('issue_sub%s' % suffix)
478 issue_prefix = kallithea.CONFIG.get('issue_prefix%s' % suffix)
478 issue_prefix = kallithea.CONFIG.get('issue_prefix%s' % suffix)
479 if issue_prefix:
479 if issue_prefix:
480 log.error('found unsupported issue_prefix%s = %r - use issue_sub%s instead', suffix, issue_prefix, suffix)
480 log.error('found unsupported issue_prefix%s = %r - use issue_sub%s instead', suffix, issue_prefix, suffix)
481 if not issue_pat:
481 if not issue_pat:
482 log.error('skipping incomplete issue pattern %r: it needs a regexp', k)
482 log.error('skipping incomplete issue pattern %r: it needs a regexp', k)
483 continue
483 continue
484 if not issue_server_link:
484 if not issue_server_link:
485 log.error('skipping incomplete issue pattern %r: it needs issue_server_link%s', k, suffix)
485 log.error('skipping incomplete issue pattern %r: it needs issue_server_link%s', k, suffix)
486 continue
486 continue
487 if issue_sub is None: # issue_sub can be empty but should be present
487 if issue_sub is None: # issue_sub can be empty but should be present
488 log.error('skipping incomplete issue pattern %r: it needs (a potentially empty) issue_sub%s', k, suffix)
488 log.error('skipping incomplete issue pattern %r: it needs (a potentially empty) issue_sub%s', k, suffix)
489 continue
489 continue
490
490
491 # Wrap tmp_urlify_issues_f with substitution of this pattern, while making sure all loop variables (and compiled regexpes) are bound
491 # Wrap tmp_urlify_issues_f with substitution of this pattern, while making sure all loop variables (and compiled regexpes) are bound
492 try:
492 try:
493 issue_re = re.compile(issue_pat)
493 issue_re = re.compile(issue_pat)
494 except re.error as e:
494 except re.error as e:
495 log.error('skipping invalid issue pattern %r: %r -> %r %r. Error: %s', k, issue_pat, issue_server_link, issue_sub, str(e))
495 log.error('skipping invalid issue pattern %r: %r -> %r %r. Error: %s', k, issue_pat, issue_server_link, issue_sub, str(e))
496 continue
496 continue
497
497
498 log.debug('issue pattern %r: %r -> %r %r', k, issue_pat, issue_server_link, issue_sub)
498 log.debug('issue pattern %r: %r -> %r %r', k, issue_pat, issue_server_link, issue_sub)
499
499
500 def issues_replace(match_obj,
500 def issues_replace(match_obj,
501 issue_server_link=issue_server_link, issue_sub=issue_sub):
501 issue_server_link=issue_server_link, issue_sub=issue_sub):
502 try:
502 try:
503 issue_url = match_obj.expand(issue_server_link)
503 issue_url = match_obj.expand(issue_server_link)
504 except (IndexError, re.error) as e:
504 except (IndexError, re.error) as e:
505 log.error('invalid issue_url setting %r -> %r %r. Error: %s', issue_pat, issue_server_link, issue_sub, str(e))
505 log.error('invalid issue_url setting %r -> %r %r. Error: %s', issue_pat, issue_server_link, issue_sub, str(e))
506 issue_url = issue_server_link
506 issue_url = issue_server_link
507 issue_url = issue_url.replace('{repo}', repo_name)
507 issue_url = issue_url.replace('{repo}', repo_name)
508 issue_url = issue_url.replace('{repo_name}', repo_name.split(kallithea.URL_SEP)[-1])
508 issue_url = issue_url.replace('{repo_name}', repo_name.split(kallithea.URL_SEP)[-1])
509 # if issue_sub is empty use the matched issue reference verbatim
509 # if issue_sub is empty use the matched issue reference verbatim
510 if not issue_sub:
510 if not issue_sub:
511 issue_text = match_obj.group()
511 issue_text = match_obj.group()
512 else:
512 else:
513 try:
513 try:
514 issue_text = match_obj.expand(issue_sub)
514 issue_text = match_obj.expand(issue_sub)
515 except (IndexError, re.error) as e:
515 except (IndexError, re.error) as e:
516 log.error('invalid issue_sub setting %r -> %r %r. Error: %s', issue_pat, issue_server_link, issue_sub, str(e))
516 log.error('invalid issue_sub setting %r -> %r %r. Error: %s', issue_pat, issue_server_link, issue_sub, str(e))
517 issue_text = match_obj.group()
517 issue_text = match_obj.group()
518
518
519 return (
519 return (
520 '<a class="issue-tracker-link" href="%(url)s">'
520 '<a class="issue-tracker-link" href="%(url)s">'
521 '%(text)s'
521 '%(text)s'
522 '</a>'
522 '</a>'
523 ) % {
523 ) % {
524 'url': issue_url,
524 'url': issue_url,
525 'text': issue_text,
525 'text': issue_text,
526 }
526 }
527
527
528 def tmp_urlify_issues_f(s, issue_re=issue_re, issues_replace=issues_replace, chain_f=tmp_urlify_issues_f):
528 def tmp_urlify_issues_f(s, issue_re=issue_re, issues_replace=issues_replace, chain_f=tmp_urlify_issues_f):
529 return issue_re.sub(issues_replace, chain_f(s))
529 return issue_re.sub(issues_replace, chain_f(s))
530
530
531 # Set tmp function globally - atomically
531 # Set tmp function globally - atomically
532 _urlify_issues_f = tmp_urlify_issues_f
532 _urlify_issues_f = tmp_urlify_issues_f
533
533
534 return _urlify_issues_f(newtext)
534 return _urlify_issues_f(newtext)
535
535
536
536
537 def render_w_mentions(source, repo_name=None):
537 def render_w_mentions(source, repo_name=None):
538 """
538 """
539 Render plain text with revision hashes and issue references urlified
539 Render plain text with revision hashes and issue references urlified
540 and with @mention highlighting.
540 and with @mention highlighting.
541 """
541 """
542 s = urlify_text(source, repo_name=repo_name)
542 s = urlify_text(source, repo_name=repo_name)
543 return literal('<div class="formatted-fixed">%s</div>' % s)
543 return literal('<div class="formatted-fixed">%s</div>' % s)
544
544
545
545
546 #
546 #
547 # Simple filters
547 # Simple filters
548 #
548 #
549
549
550 def shorter(s, size=20, firstline=False, postfix='...'):
550 def shorter(s, size=20, firstline=False, postfix='...'):
551 """Truncate s to size, including the postfix string if truncating.
551 """Truncate s to size, including the postfix string if truncating.
552 If firstline, truncate at newline.
552 If firstline, truncate at newline.
553 """
553 """
554 if firstline:
554 if firstline:
555 s = s.split('\n', 1)[0].rstrip()
555 s = s.split('\n', 1)[0].rstrip()
556 if len(s) > size:
556 if len(s) > size:
557 return s[:size - len(postfix)] + postfix
557 return s[:size - len(postfix)] + postfix
558 return s
558 return s
559
559
560
560
561 def age(prevdate, show_short_version=False, now=None):
561 def age(prevdate, show_short_version=False, now=None):
562 """
562 """
563 turns a datetime into an age string.
563 turns a datetime into an age string.
564 If show_short_version is True, then it will generate a not so accurate but shorter string,
564 If show_short_version is True, then it will generate a not so accurate but shorter string,
565 example: 2days ago, instead of 2 days and 23 hours ago.
565 example: 2days ago, instead of 2 days and 23 hours ago.
566
566
567 :param prevdate: datetime object
567 :param prevdate: datetime object
568 :param show_short_version: if it should approximate the date and return a shorter string
568 :param show_short_version: if it should approximate the date and return a shorter string
569 :rtype: str
569 :rtype: str
570 :returns: str words describing age
570 :returns: str words describing age
571 """
571 """
572 now = now or datetime.datetime.now()
572 now = now or datetime.datetime.now()
573 order = ['year', 'month', 'day', 'hour', 'minute', 'second']
573 order = ['year', 'month', 'day', 'hour', 'minute', 'second']
574 deltas = {}
574 deltas = {}
575 future = False
575 future = False
576
576
577 if prevdate > now:
577 if prevdate > now:
578 now, prevdate = prevdate, now
578 now, prevdate = prevdate, now
579 future = True
579 future = True
580 if future:
580 if future:
581 prevdate = prevdate.replace(microsecond=0)
581 prevdate = prevdate.replace(microsecond=0)
582 # Get date parts deltas
582 # Get date parts deltas
583 for part in order:
583 for part in order:
584 d = relativedelta.relativedelta(now, prevdate)
584 d = relativedelta.relativedelta(now, prevdate)
585 deltas[part] = getattr(d, part + 's')
585 deltas[part] = getattr(d, part + 's')
586
586
587 # Fix negative offsets (there is 1 second between 10:59:59 and 11:00:00,
587 # Fix negative offsets (there is 1 second between 10:59:59 and 11:00:00,
588 # not 1 hour, -59 minutes and -59 seconds)
588 # not 1 hour, -59 minutes and -59 seconds)
589 for num, length in [(5, 60), (4, 60), (3, 24)]: # seconds, minutes, hours
589 for num, length in [(5, 60), (4, 60), (3, 24)]: # seconds, minutes, hours
590 part = order[num]
590 part = order[num]
591 carry_part = order[num - 1]
591 carry_part = order[num - 1]
592
592
593 if deltas[part] < 0:
593 if deltas[part] < 0:
594 deltas[part] += length
594 deltas[part] += length
595 deltas[carry_part] -= 1
595 deltas[carry_part] -= 1
596
596
597 # Same thing for days except that the increment depends on the (variable)
597 # Same thing for days except that the increment depends on the (variable)
598 # number of days in the month
598 # number of days in the month
599 month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
599 month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
600 if deltas['day'] < 0:
600 if deltas['day'] < 0:
601 if prevdate.month == 2 and (prevdate.year % 4 == 0 and
601 if prevdate.month == 2 and (prevdate.year % 4 == 0 and
602 (prevdate.year % 100 != 0 or prevdate.year % 400 == 0)
602 (prevdate.year % 100 != 0 or prevdate.year % 400 == 0)
603 ):
603 ):
604 deltas['day'] += 29
604 deltas['day'] += 29
605 else:
605 else:
606 deltas['day'] += month_lengths[prevdate.month - 1]
606 deltas['day'] += month_lengths[prevdate.month - 1]
607
607
608 deltas['month'] -= 1
608 deltas['month'] -= 1
609
609
610 if deltas['month'] < 0:
610 if deltas['month'] < 0:
611 deltas['month'] += 12
611 deltas['month'] += 12
612 deltas['year'] -= 1
612 deltas['year'] -= 1
613
613
614 # In short version, we want nicer handling of ages of more than a year
614 # In short version, we want nicer handling of ages of more than a year
615 if show_short_version:
615 if show_short_version:
616 if deltas['year'] == 1:
616 if deltas['year'] == 1:
617 # ages between 1 and 2 years: show as months
617 # ages between 1 and 2 years: show as months
618 deltas['month'] += 12
618 deltas['month'] += 12
619 deltas['year'] = 0
619 deltas['year'] = 0
620 if deltas['year'] >= 2:
620 if deltas['year'] >= 2:
621 # ages 2+ years: round
621 # ages 2+ years: round
622 if deltas['month'] > 6:
622 if deltas['month'] > 6:
623 deltas['year'] += 1
623 deltas['year'] += 1
624 deltas['month'] = 0
624 deltas['month'] = 0
625
625
626 # Format the result
626 # Format the result
627 fmt_funcs = {
627 fmt_funcs = {
628 'year': lambda d: ungettext('%d year', '%d years', d) % d,
628 'year': lambda d: ungettext('%d year', '%d years', d) % d,
629 'month': lambda d: ungettext('%d month', '%d months', d) % d,
629 'month': lambda d: ungettext('%d month', '%d months', d) % d,
630 'day': lambda d: ungettext('%d day', '%d days', d) % d,
630 'day': lambda d: ungettext('%d day', '%d days', d) % d,
631 'hour': lambda d: ungettext('%d hour', '%d hours', d) % d,
631 'hour': lambda d: ungettext('%d hour', '%d hours', d) % d,
632 'minute': lambda d: ungettext('%d minute', '%d minutes', d) % d,
632 'minute': lambda d: ungettext('%d minute', '%d minutes', d) % d,
633 'second': lambda d: ungettext('%d second', '%d seconds', d) % d,
633 'second': lambda d: ungettext('%d second', '%d seconds', d) % d,
634 }
634 }
635
635
636 for i, part in enumerate(order):
636 for i, part in enumerate(order):
637 value = deltas[part]
637 value = deltas[part]
638 if value == 0:
638 if value == 0:
639 continue
639 continue
640
640
641 if i < 5:
641 if i < 5:
642 sub_part = order[i + 1]
642 sub_part = order[i + 1]
643 sub_value = deltas[sub_part]
643 sub_value = deltas[sub_part]
644 else:
644 else:
645 sub_value = 0
645 sub_value = 0
646
646
647 if sub_value == 0 or show_short_version:
647 if sub_value == 0 or show_short_version:
648 if future:
648 if future:
649 return _('in %s') % fmt_funcs[part](value)
649 return _('in %s') % fmt_funcs[part](value)
650 else:
650 else:
651 return _('%s ago') % fmt_funcs[part](value)
651 return _('%s ago') % fmt_funcs[part](value)
652 if future:
652 if future:
653 return _('in %s and %s') % (fmt_funcs[part](value),
653 return _('in %s and %s') % (fmt_funcs[part](value),
654 fmt_funcs[sub_part](sub_value))
654 fmt_funcs[sub_part](sub_value))
655 else:
655 else:
656 return _('%s and %s ago') % (fmt_funcs[part](value),
656 return _('%s and %s ago') % (fmt_funcs[part](value),
657 fmt_funcs[sub_part](sub_value))
657 fmt_funcs[sub_part](sub_value))
658
658
659 return _('just now')
659 return _('just now')
660
660
661
661
662 def fmt_date(date):
662 def fmt_date(date):
663 if date:
663 if date:
664 return date.strftime("%Y-%m-%d %H:%M:%S")
664 return date.strftime("%Y-%m-%d %H:%M:%S")
665 return ""
665 return ""
666
666
667
667
668 def capitalize(x):
668 def capitalize(x):
669 return x.capitalize()
669 return x.capitalize()
670
670
671
671
672 def short_id(x):
672 def short_id(x):
673 return x[:12]
673 return x[:12]
General Comments 0
You need to be logged in to leave comments. Login now