##// END OF EJS Templates
release: merge back stable branch into default
milka -
r4642:cced0269 merge default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -0,0 +1,46 b''
1 |RCE| 4.24.1 |RNS|
2 ------------------
3
4 Release Date
5 ^^^^^^^^^^^^
6
7 - 2021-02-04
8
9
10 New Features
11 ^^^^^^^^^^^^
12
13
14
15 General
16 ^^^^^^^
17
18 - Core: added statsd client for statistics usage.
19 - Clone urls: allow custom clone by id template so users can set clone-by-id as default.
20 - Automation: enable check for new version for EE edition as automation task that will send notifications when new RhodeCode version is available
21
22 Security
23 ^^^^^^^^
24
25
26
27 Performance
28 ^^^^^^^^^^^
29
30 - Core: bumped git to 2.30.0
31
32
33 Fixes
34 ^^^^^
35
36 - Comments: add ability to resolve todos from the side-bar. This should prevent situations
37 when a TODO was left over in outdated/removed code pieces, and users needs to search to resolve them.
38 - Pull requests: fixed a case when template marker was used in description field causing 500 errors on commenting.
39 - Merges: fixed excessive data saved in merge metadata that could not fit inside the DB table.
40 - Exceptions: fixed problem with exceptions formatting resulting in limited exception data reporting.
41
42
43 Upgrade notes
44 ^^^^^^^^^^^^^
45
46 - Un-scheduled release addressing problems in 4.24.X releases.
@@ -0,0 +1,12 b''
1 diff -rup pytest-4.6.5-orig/setup.py pytest-4.6.5/setup.py
2 --- pytest-4.6.5-orig/setup.py 2018-04-10 10:23:04.000000000 +0200
3 +++ pytest-4.6.5/setup.py 2018-04-10 10:23:34.000000000 +0200
4 @@ -24,7 +24,7 @@ INSTALL_REQUIRES = [
5 def main():
6 setup(
7 use_scm_version={"write_to": "src/_pytest/_version.py"},
8 - setup_requires=["setuptools-scm", "setuptools>=40.0"],
9 + setup_requires=["setuptools-scm", "setuptools<=42.0"],
10 package_dir={"": "src"},
11 # fmt: off
12 extras_require={ No newline at end of file
@@ -0,0 +1,46 b''
1 from __future__ import absolute_import, division, unicode_literals
2
3 import logging
4
5 from .stream import TCPStatsClient, UnixSocketStatsClient # noqa
6 from .udp import StatsClient # noqa
7
8 HOST = 'localhost'
9 PORT = 8125
10 IPV6 = False
11 PREFIX = None
12 MAXUDPSIZE = 512
13
14 log = logging.getLogger('rhodecode.statsd')
15
16
17 def statsd_config(config, prefix='statsd.'):
18 _config = {}
19 for key in config.keys():
20 if key.startswith(prefix):
21 _config[key[len(prefix):]] = config[key]
22 return _config
23
24
25 def client_from_config(configuration, prefix='statsd.', **kwargs):
26 from pyramid.settings import asbool
27
28 _config = statsd_config(configuration, prefix)
29 statsd_enabled = asbool(_config.pop('enabled', False))
30 if not statsd_enabled:
31 log.debug('statsd client not enabled by statsd.enabled = flag, skipping...')
32 return
33
34 host = _config.pop('statsd_host', HOST)
35 port = _config.pop('statsd_port', PORT)
36 prefix = _config.pop('statsd_prefix', PREFIX)
37 maxudpsize = _config.pop('statsd_maxudpsize', MAXUDPSIZE)
38 ipv6 = asbool(_config.pop('statsd_ipv6', IPV6))
39 log.debug('configured statsd client %s:%s', host, port)
40
41 return StatsClient(
42 host=host, port=port, prefix=prefix, maxudpsize=maxudpsize, ipv6=ipv6)
43
44
45 def get_statsd_client(request):
46 return client_from_config(request.registry.settings)
@@ -0,0 +1,107 b''
1 from __future__ import absolute_import, division, unicode_literals
2
3 import random
4 from collections import deque
5 from datetime import timedelta
6
7 from .timer import Timer
8
9
10 class StatsClientBase(object):
11 """A Base class for various statsd clients."""
12
13 def close(self):
14 """Used to close and clean up any underlying resources."""
15 raise NotImplementedError()
16
17 def _send(self):
18 raise NotImplementedError()
19
20 def pipeline(self):
21 raise NotImplementedError()
22
23 def timer(self, stat, rate=1):
24 return Timer(self, stat, rate)
25
26 def timing(self, stat, delta, rate=1):
27 """
28 Send new timing information.
29
30 `delta` can be either a number of milliseconds or a timedelta.
31 """
32 if isinstance(delta, timedelta):
33 # Convert timedelta to number of milliseconds.
34 delta = delta.total_seconds() * 1000.
35 self._send_stat(stat, '%0.6f|ms' % delta, rate)
36
37 def incr(self, stat, count=1, rate=1):
38 """Increment a stat by `count`."""
39 self._send_stat(stat, '%s|c' % count, rate)
40
41 def decr(self, stat, count=1, rate=1):
42 """Decrement a stat by `count`."""
43 self.incr(stat, -count, rate)
44
45 def gauge(self, stat, value, rate=1, delta=False):
46 """Set a gauge value."""
47 if value < 0 and not delta:
48 if rate < 1:
49 if random.random() > rate:
50 return
51 with self.pipeline() as pipe:
52 pipe._send_stat(stat, '0|g', 1)
53 pipe._send_stat(stat, '%s|g' % value, 1)
54 else:
55 prefix = '+' if delta and value >= 0 else ''
56 self._send_stat(stat, '%s%s|g' % (prefix, value), rate)
57
58 def set(self, stat, value, rate=1):
59 """Set a set value."""
60 self._send_stat(stat, '%s|s' % value, rate)
61
62 def _send_stat(self, stat, value, rate):
63 self._after(self._prepare(stat, value, rate))
64
65 def _prepare(self, stat, value, rate):
66 if rate < 1:
67 if random.random() > rate:
68 return
69 value = '%s|@%s' % (value, rate)
70
71 if self._prefix:
72 stat = '%s.%s' % (self._prefix, stat)
73
74 return '%s:%s' % (stat, value)
75
76 def _after(self, data):
77 if data:
78 self._send(data)
79
80
81 class PipelineBase(StatsClientBase):
82
83 def __init__(self, client):
84 self._client = client
85 self._prefix = client._prefix
86 self._stats = deque()
87
88 def _send(self):
89 raise NotImplementedError()
90
91 def _after(self, data):
92 if data is not None:
93 self._stats.append(data)
94
95 def __enter__(self):
96 return self
97
98 def __exit__(self, typ, value, tb):
99 self.send()
100
101 def send(self):
102 if not self._stats:
103 return
104 self._send()
105
106 def pipeline(self):
107 return self.__class__(self)
@@ -0,0 +1,75 b''
1 from __future__ import absolute_import, division, unicode_literals
2
3 import socket
4
5 from .base import StatsClientBase, PipelineBase
6
7
8 class StreamPipeline(PipelineBase):
9 def _send(self):
10 self._client._after('\n'.join(self._stats))
11 self._stats.clear()
12
13
14 class StreamClientBase(StatsClientBase):
15 def connect(self):
16 raise NotImplementedError()
17
18 def close(self):
19 if self._sock and hasattr(self._sock, 'close'):
20 self._sock.close()
21 self._sock = None
22
23 def reconnect(self):
24 self.close()
25 self.connect()
26
27 def pipeline(self):
28 return StreamPipeline(self)
29
30 def _send(self, data):
31 """Send data to statsd."""
32 if not self._sock:
33 self.connect()
34 self._do_send(data)
35
36 def _do_send(self, data):
37 self._sock.sendall(data.encode('ascii') + b'\n')
38
39
40 class TCPStatsClient(StreamClientBase):
41 """TCP version of StatsClient."""
42
43 def __init__(self, host='localhost', port=8125, prefix=None,
44 timeout=None, ipv6=False):
45 """Create a new client."""
46 self._host = host
47 self._port = port
48 self._ipv6 = ipv6
49 self._timeout = timeout
50 self._prefix = prefix
51 self._sock = None
52
53 def connect(self):
54 fam = socket.AF_INET6 if self._ipv6 else socket.AF_INET
55 family, _, _, _, addr = socket.getaddrinfo(
56 self._host, self._port, fam, socket.SOCK_STREAM)[0]
57 self._sock = socket.socket(family, socket.SOCK_STREAM)
58 self._sock.settimeout(self._timeout)
59 self._sock.connect(addr)
60
61
62 class UnixSocketStatsClient(StreamClientBase):
63 """Unix domain socket version of StatsClient."""
64
65 def __init__(self, socket_path, prefix=None, timeout=None):
66 """Create a new client."""
67 self._socket_path = socket_path
68 self._timeout = timeout
69 self._prefix = prefix
70 self._sock = None
71
72 def connect(self):
73 self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
74 self._sock.settimeout(self._timeout)
75 self._sock.connect(self._socket_path)
@@ -0,0 +1,71 b''
1 from __future__ import absolute_import, division, unicode_literals
2
3 import functools
4
5 # Use timer that's not susceptible to time of day adjustments.
6 try:
7 # perf_counter is only present on Py3.3+
8 from time import perf_counter as time_now
9 except ImportError:
10 # fall back to using time
11 from time import time as time_now
12
13
14 def safe_wraps(wrapper, *args, **kwargs):
15 """Safely wraps partial functions."""
16 while isinstance(wrapper, functools.partial):
17 wrapper = wrapper.func
18 return functools.wraps(wrapper, *args, **kwargs)
19
20
21 class Timer(object):
22 """A context manager/decorator for statsd.timing()."""
23
24 def __init__(self, client, stat, rate=1):
25 self.client = client
26 self.stat = stat
27 self.rate = rate
28 self.ms = None
29 self._sent = False
30 self._start_time = None
31
32 def __call__(self, f):
33 """Thread-safe timing function decorator."""
34 @safe_wraps(f)
35 def _wrapped(*args, **kwargs):
36 start_time = time_now()
37 try:
38 return f(*args, **kwargs)
39 finally:
40 elapsed_time_ms = 1000.0 * (time_now() - start_time)
41 self.client.timing(self.stat, elapsed_time_ms, self.rate)
42 return _wrapped
43
44 def __enter__(self):
45 return self.start()
46
47 def __exit__(self, typ, value, tb):
48 self.stop()
49
50 def start(self):
51 self.ms = None
52 self._sent = False
53 self._start_time = time_now()
54 return self
55
56 def stop(self, send=True):
57 if self._start_time is None:
58 raise RuntimeError('Timer has not started.')
59 dt = time_now() - self._start_time
60 self.ms = 1000.0 * dt # Convert to milliseconds.
61 if send:
62 self.send()
63 return self
64
65 def send(self):
66 if self.ms is None:
67 raise RuntimeError('No data recorded.')
68 if self._sent:
69 raise RuntimeError('Already sent data.')
70 self._sent = True
71 self.client.timing(self.stat, self.ms, self.rate)
@@ -0,0 +1,55 b''
1 from __future__ import absolute_import, division, unicode_literals
2
3 import socket
4
5 from .base import StatsClientBase, PipelineBase
6
7
8 class Pipeline(PipelineBase):
9
10 def __init__(self, client):
11 super(Pipeline, self).__init__(client)
12 self._maxudpsize = client._maxudpsize
13
14 def _send(self):
15 data = self._stats.popleft()
16 while self._stats:
17 # Use popleft to preserve the order of the stats.
18 stat = self._stats.popleft()
19 if len(stat) + len(data) + 1 >= self._maxudpsize:
20 self._client._after(data)
21 data = stat
22 else:
23 data += '\n' + stat
24 self._client._after(data)
25
26
27 class StatsClient(StatsClientBase):
28 """A client for statsd."""
29
30 def __init__(self, host='localhost', port=8125, prefix=None,
31 maxudpsize=512, ipv6=False):
32 """Create a new client."""
33 fam = socket.AF_INET6 if ipv6 else socket.AF_INET
34 family, _, _, _, addr = socket.getaddrinfo(
35 host, port, fam, socket.SOCK_DGRAM)[0]
36 self._addr = addr
37 self._sock = socket.socket(family, socket.SOCK_DGRAM)
38 self._prefix = prefix
39 self._maxudpsize = maxudpsize
40
41 def _send(self, data):
42 """Send data to statsd."""
43 try:
44 self._sock.sendto(data.encode('ascii'), self._addr)
45 except (socket.error, RuntimeError):
46 # No time for love, Dr. Jones!
47 pass
48
49 def close(self):
50 if self._sock and hasattr(self._sock, 'close'):
51 self._sock.close()
52 self._sock = None
53
54 def pipeline(self):
55 return Pipeline(self)
@@ -0,0 +1,64 b''
1 # -*- coding: utf-8 -*-
2
3 import logging
4 from sqlalchemy import *
5
6 from alembic.migration import MigrationContext
7 from alembic.operations import Operations
8
9 from rhodecode.lib.dbmigrate.versions import _reset_base
10 from rhodecode.model import meta, init_model_encryption
11
12
13 log = logging.getLogger(__name__)
14
15
16 def upgrade(migrate_engine):
17 """
18 Upgrade operations go here.
19 Don't create your own engine; bind migrate_engine to your metadata
20 """
21 _reset_base(migrate_engine)
22 from rhodecode.lib.dbmigrate.schema import db_4_20_0_0 as db
23
24 init_model_encryption(db)
25
26 # issue fixups
27 fixups(db, meta.Session)
28
29
30 def downgrade(migrate_engine):
31 meta = MetaData()
32 meta.bind = migrate_engine
33
34
35 def fixups(models, _SESSION):
36 # now create new changed value of clone_url
37 Optional = models.Optional
38
39 def get_by_name(cls, key):
40 return cls.query().filter(cls.app_settings_name == key).scalar()
41
42 def create_or_update(cls, key, val=Optional(''), type_=Optional('unicode')):
43 res = get_by_name(cls, key)
44 if not res:
45 val = Optional.extract(val)
46 type_ = Optional.extract(type_)
47 res = cls(key, val, type_)
48 else:
49 res.app_settings_name = key
50 if not isinstance(val, Optional):
51 # update if set
52 res.app_settings_value = val
53 if not isinstance(type_, Optional):
54 # update if set
55 res.app_settings_type = type_
56 return res
57
58 clone_uri_tmpl = models.Repository.DEFAULT_CLONE_URI_ID
59 print('settings new clone by url template to %s' % clone_uri_tmpl)
60
61 sett = create_or_update(models.RhodeCodeSetting,
62 'clone_uri_id_tmpl', clone_uri_tmpl, 'unicode')
63 _SESSION().add(sett)
64 _SESSION.commit()
@@ -0,0 +1,32 b''
1 ## -*- coding: utf-8 -*-
2 <%inherit file="base.mako"/>
3
4 <%def name="subject()" filter="n,trim,whitespace_filter">
5 New Version of RhodeCode is available !
6 </%def>
7
8 ## plain text version of the email. Empty by default
9 <%def name="body_plaintext()" filter="n,trim">
10 A new version of RhodeCode is available!
11
12 Your version: ${current_ver}
13 New version: ${latest_ver}
14
15 Release notes:
16
17 https://docs.rhodecode.com/RhodeCode-Enterprise/release-notes/release-notes-${latest_ver}.html
18 </%def>
19
20 ## BODY GOES BELOW
21
22 <h3>A new version of RhodeCode is available!</h3>
23 <br/>
24 Your version: ${current_ver}<br/>
25 New version: <strong>${latest_ver}</strong><br/>
26
27 <h4>Release notes</h4>
28
29 <a href="https://docs.rhodecode.com/RhodeCode-Enterprise/release-notes/release-notes-${latest_ver}.html">
30 https://docs.rhodecode.com/RhodeCode-Enterprise/release-notes/release-notes-${latest_ver}.html
31 </a>
32
@@ -73,3 +73,5 b' 90734aac31ee4563bbe665a43ff73190cc762275'
73 73 a9655707f7cf4146affc51c12fe5ed8e02898a57 v4.23.0
74 74 56310d93b33b97535908ef9c7b0985b89bb7fad2 v4.23.1
75 75 7637c38528fa38c1eabc1fde6a869c20995a0da7 v4.23.2
76 6aeb4ac3ef7f0ac699c914740dad3688c9495e83 v4.24.0
77 6eaf953da06e468a4c4e5239d3d0e700bda6b163 v4.24.1
@@ -1,4 +1,4 b''
1 |RCE| 4.23.0 |RNS|
1 |RCE| 4.24.0 |RNS|
2 2 ------------------
3 3
4 4 Release Date
@@ -16,14 +16,16 b' New Features'
16 16 Can be used for backups etc.
17 17 - Pull requests: expose commit versions in the pull-request commit list.
18 18
19
19 20 General
20 21 ^^^^^^^
21 22
22 23 - Deps: bumped redis to 3.5.3
23 - rcextensions: improve examples
24 - Rcextensions: improve examples for some usage.
24 25 - Setup: added optional parameters to apply a default license, or skip re-creation of database at install.
25 26 - Docs: update headers for NGINX
26 27 - Beaker cache: remove no longer used beaker cache init
28 - Installation: the installer no longer requires gzip and bzip packages, and works on python 2 and 3
27 29
28 30
29 31 Security
@@ -9,6 +9,7 b' Release Notes'
9 9 .. toctree::
10 10 :maxdepth: 1
11 11
12 release-notes-4.24.1.rst
12 13 release-notes-4.24.0.rst
13 14 release-notes-4.23.2.rst
14 15 release-notes-4.23.1.rst
@@ -274,6 +274,12 b' self: super: {'
274 274 ];
275 275 });
276 276
277 "pytest" = super."pytest".override (attrs: {
278 patches = [
279 ./patches/pytest/setuptools.patch
280 ];
281 });
282
277 283 # Avoid that base packages screw up the build process
278 284 inherit (basePythonPackages)
279 285 setuptools;
@@ -48,7 +48,7 b' PYRAMID_SETTINGS = {}'
48 48 EXTENSIONS = {}
49 49
50 50 __version__ = ('.'.join((str(each) for each in VERSION[:3])))
51 __dbversion__ = 112 # defines current db version for migrations
51 __dbversion__ = 113 # defines current db version for migrations
52 52 __platform__ = platform.system()
53 53 __license__ = 'AGPLv3, and Commercial License'
54 54 __author__ = 'RhodeCode GmbH'
@@ -384,6 +384,7 b' class AdminSettingsView(BaseAppView):'
384 384 ('markup_renderer', 'rhodecode_markup_renderer', 'unicode'),
385 385 ('gravatar_url', 'rhodecode_gravatar_url', 'unicode'),
386 386 ('clone_uri_tmpl', 'rhodecode_clone_uri_tmpl', 'unicode'),
387 ('clone_uri_id_tmpl', 'rhodecode_clone_uri_id_tmpl', 'unicode'),
387 388 ('clone_uri_ssh_tmpl', 'rhodecode_clone_uri_ssh_tmpl', 'unicode'),
388 389 ('support_url', 'rhodecode_support_url', 'unicode'),
389 390 ('show_revision_number', 'rhodecode_show_revision_number', 'bool'),
@@ -102,6 +102,11 b' Check if we should use full-topic or min'
102 102 'date': datetime.datetime.now(),
103 103 },
104 104
105 'update_available': {
106 'current_ver': '4.23.0',
107 'latest_ver': '4.24.0',
108 },
109
105 110 'exception': {
106 111 'email_prefix': '[RHODECODE ERROR]',
107 112 'exc_id': exc_traceback['exc_id'],
@@ -420,6 +420,27 b' class TestPullrequestsView(object):'
420 420 assert pull_request.title == 'New title'
421 421 assert pull_request.description == 'New description'
422 422
423 def test_edit_title_description(self, pr_util, csrf_token):
424 pull_request = pr_util.create_pull_request()
425 pull_request_id = pull_request.pull_request_id
426
427 response = self.app.post(
428 route_path('pullrequest_update',
429 repo_name=pull_request.target_repo.repo_name,
430 pull_request_id=pull_request_id),
431 params={
432 'edit_pull_request': 'true',
433 'title': 'New title {} {2} {foo}',
434 'description': 'New description',
435 'csrf_token': csrf_token})
436
437 assert_session_flash(
438 response, u'Pull request title & description updated.',
439 category='success')
440
441 pull_request = PullRequest.get(pull_request_id)
442 assert pull_request.title_safe == 'New title {{}} {{2}} {{foo}}'
443
423 444 def test_edit_title_description_closed(self, pr_util, csrf_token):
424 445 pull_request = pr_util.create_pull_request()
425 446 pull_request_id = pull_request.pull_request_id
@@ -83,14 +83,10 b' class RepoSummaryView(RepoAppView):'
83 83 if self._rhodecode_user.username != User.DEFAULT_USER:
84 84 username = safe_str(self._rhodecode_user.username)
85 85
86 _def_clone_uri = _def_clone_uri_id = c.clone_uri_tmpl
86 _def_clone_uri = c.clone_uri_tmpl
87 _def_clone_uri_id = c.clone_uri_id_tmpl
87 88 _def_clone_uri_ssh = c.clone_uri_ssh_tmpl
88 89
89 if '{repo}' in _def_clone_uri:
90 _def_clone_uri_id = _def_clone_uri.replace('{repo}', '_{repoid}')
91 elif '{repoid}' in _def_clone_uri:
92 _def_clone_uri_id = _def_clone_uri.replace('_{repoid}', '{repo}')
93
94 90 c.clone_repo_url = self.db_repo.clone_url(
95 91 user=username, uri_tmpl=_def_clone_uri)
96 92 c.clone_repo_url_id = self.db_repo.clone_url(
@@ -340,6 +340,10 b' def includeme(config, auth_resources=Non'
340 340 'rhodecode.lib.request_counter.get_request_counter',
341 341 'request_count')
342 342
343 config.add_request_method(
344 'rhodecode.lib._vendor.statsd.get_statsd_client',
345 'statsd', reify=True)
346
343 347 # Set the authorization policy.
344 348 authz_policy = ACLAuthorizationPolicy()
345 349 config.set_authorization_policy(authz_policy)
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -342,6 +342,7 b' def attach_context_attributes(context, r'
342 342 if request.GET.get('default_encoding'):
343 343 context.default_encodings.insert(0, request.GET.get('default_encoding'))
344 344 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
345 context.clone_uri_id_tmpl = rc_config.get('rhodecode_clone_uri_id_tmpl')
345 346 context.clone_uri_ssh_tmpl = rc_config.get('rhodecode_clone_uri_ssh_tmpl')
346 347
347 348 # INI stored
@@ -33,9 +33,9 b' from email.utils import formatdate'
33 33
34 34 import rhodecode
35 35 from rhodecode.lib import audit_logger
36 from rhodecode.lib.celerylib import get_logger, async_task, RequestContextTask
36 from rhodecode.lib.celerylib import get_logger, async_task, RequestContextTask, run_task
37 37 from rhodecode.lib import hooks_base
38 from rhodecode.lib.utils2 import safe_int, str2bool
38 from rhodecode.lib.utils2 import safe_int, str2bool, aslist
39 39 from rhodecode.model.db import (
40 40 Session, IntegrityError, true, Repository, RepoGroup, User)
41 41
@@ -338,15 +338,39 b' def repo_maintenance(repoid):'
338 338
339 339
340 340 @async_task(ignore_result=True)
341 def check_for_update():
341 def check_for_update(send_email_notification=True, email_recipients=None):
342 342 from rhodecode.model.update import UpdateModel
343 from rhodecode.model.notification import EmailNotificationModel
344
345 log = get_logger(check_for_update)
343 346 update_url = UpdateModel().get_update_url()
344 347 cur_ver = rhodecode.__version__
345 348
346 349 try:
347 350 data = UpdateModel().get_update_data(update_url)
348 latest = data['versions'][0]
349 UpdateModel().store_version(latest['version'])
351
352 current_ver = UpdateModel().get_stored_version(fallback=cur_ver)
353 latest_ver = data['versions'][0]['version']
354 UpdateModel().store_version(latest_ver)
355
356 if send_email_notification:
357 log.debug('Send email notification is enabled. '
358 'Current RhodeCode version: %s, latest known: %s', current_ver, latest_ver)
359 if UpdateModel().is_outdated(current_ver, latest_ver):
360
361 email_kwargs = {
362 'current_ver': current_ver,
363 'latest_ver': latest_ver,
364 }
365
366 (subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
367 EmailNotificationModel.TYPE_UPDATE_AVAILABLE, **email_kwargs)
368
369 email_recipients = aslist(email_recipients, sep=',') or \
370 [user.email for user in User.get_all_super_admins()]
371 run_task(send_email, email_recipients, subject,
372 email_body_plaintext, email_body)
373
350 374 except Exception:
351 375 pass
352 376
@@ -595,12 +595,13 b' class DbManage(object):'
595 595 # Visual
596 596 ('show_public_icon', True, 'bool'),
597 597 ('show_private_icon', True, 'bool'),
598 ('stylify_metatags', False, 'bool'),
598 ('stylify_metatags', True, 'bool'),
599 599 ('dashboard_items', 100, 'int'),
600 600 ('admin_grid_items', 25, 'int'),
601 601
602 602 ('markup_renderer', 'markdown', 'unicode'),
603 603
604 ('repository_fields', True, 'bool'),
604 605 ('show_version', True, 'bool'),
605 606 ('show_revision_number', True, 'bool'),
606 607 ('show_sha_length', 12, 'int'),
@@ -609,6 +610,7 b' class DbManage(object):'
609 610 ('gravatar_url', User.DEFAULT_GRAVATAR_URL, 'unicode'),
610 611
611 612 ('clone_uri_tmpl', Repository.DEFAULT_CLONE_URI, 'unicode'),
613 ('clone_uri_id_tmpl', Repository.DEFAULT_CLONE_URI_ID, 'unicode'),
612 614 ('clone_uri_ssh_tmpl', Repository.DEFAULT_CLONE_URI_SSH, 'unicode'),
613 615 ('support_url', '', 'unicode'),
614 616 ('update_url', RhodeCodeSetting.DEFAULT_UPDATE_URL, 'unicode'),
@@ -37,21 +37,29 b' class RequestWrapperTween(object):'
37 37
38 38 # one-time configuration code goes here
39 39
40 def _get_user_info(self, request):
41 user = get_current_rhodecode_user(request)
42 if not user:
43 user = AuthUser.repr_user(ip=get_ip_addr(request.environ))
44 return user
45
40 46 def __call__(self, request):
41 47 start = time.time()
42 48 log.debug('Starting request time measurement')
43 49 try:
44 50 response = self.handler(request)
45 51 finally:
46 end = time.time()
47 total = end - start
48 52 count = request.request_count()
49 53 _ver_ = rhodecode.__version__
50 default_user_info = AuthUser.repr_user(ip=get_ip_addr(request.environ))
51 user_info = get_current_rhodecode_user(request) or default_user_info
54 statsd = request.statsd
55 total = time.time() - start
56 if statsd:
57 statsd.timing('rhodecode.req.timing', total)
58 statsd.incr('rhodecode.req.count')
59
52 60 log.info(
53 61 'Req[%4s] %s %s Request to %s time: %.4fs [%s], RhodeCode %s',
54 count, user_info, request.environ.get('REQUEST_METHOD'),
62 count, self._get_user_info(request), request.environ.get('REQUEST_METHOD'),
55 63 safe_str(get_access_path(request.environ)), total,
56 64 get_user_agent(request. environ), _ver_
57 65 )
@@ -765,7 +765,14 b' class MercurialRepository(BaseRepository'
765 765
766 766 try:
767 767 if target_ref.type == 'branch' and len(self._heads(target_ref.name)) != 1:
768 heads = '\n,'.join(self._heads(target_ref.name))
768 heads_all = self._heads(target_ref.name)
769 max_heads = 10
770 if len(heads_all) > max_heads:
771 heads = '\n,'.join(
772 heads_all[:max_heads] +
773 ['and {} more.'.format(len(heads_all)-max_heads)])
774 else:
775 heads = '\n,'.join(heads_all)
769 776 metadata = {
770 777 'target_ref': target_ref,
771 778 'source_ref': source_ref,
@@ -854,7 +861,16 b' class MercurialRepository(BaseRepository'
854 861 except RepositoryError as e:
855 862 log.exception('Failure when doing local merge on hg shadow repo')
856 863 if isinstance(e, UnresolvedFilesInRepo):
857 metadata['unresolved_files'] = '\n* conflict: ' + ('\n * conflict: '.join(e.args[0]))
864 all_conflicts = list(e.args[0])
865 max_conflicts = 20
866 if len(all_conflicts) > max_conflicts:
867 conflicts = all_conflicts[:max_conflicts] \
868 + ['and {} more.'.format(len(all_conflicts)-max_conflicts)]
869 else:
870 conflicts = all_conflicts
871 metadata['unresolved_files'] = \
872 '\n* conflict: ' + \
873 ('\n * conflict: '.join(conflicts))
858 874
859 875 merge_possible = False
860 876 merge_failure_reason = MergeFailureReason.MERGE_FAILED
@@ -220,7 +220,7 b' def map_vcs_exceptions(func):'
220 220 if any(e.args):
221 221 _args = [a for a in e.args]
222 222 # replace the first argument with a prefix exc name
223 args = ['{}:'.format(exc_name, _args[0] if _args else '?')] + _args[1:]
223 args = ['{}:{}'.format(exc_name, _args[0] if _args else '?')] + _args[1:]
224 224 else:
225 225 args = [__traceback_info__ or '{}: UnhandledException'.format(exc_name)]
226 226 if debug or __traceback_info__ and kind not in ['unhandled', 'lookup']:
@@ -336,6 +336,7 b' class CommentsModel(BaseModel):'
336 336 comment.author = user
337 337 resolved_comment = self.__get_commit_comment(
338 338 validated_kwargs['resolves_comment_id'])
339
339 340 # check if the comment actually belongs to this PR
340 341 if resolved_comment and resolved_comment.pull_request and \
341 342 resolved_comment.pull_request != pull_request:
@@ -351,6 +352,10 b' class CommentsModel(BaseModel):'
351 352 # comment not bound to this repo, forbid
352 353 resolved_comment = None
353 354
355 if resolved_comment and resolved_comment.resolved_by:
356 # if this comment is already resolved, don't mark it again!
357 resolved_comment = None
358
354 359 comment.resolved_comment = resolved_comment
355 360
356 361 pull_request_id = pull_request
@@ -4220,6 +4220,12 b' class _PullRequestBase(BaseModel):'
4220 4220 return True
4221 4221 return False
4222 4222
4223 @property
4224 def title_safe(self):
4225 return self.title\
4226 .replace('{', '{{')\
4227 .replace('}', '}}')
4228
4223 4229 @hybrid_property
4224 4230 def description_safe(self):
4225 4231 from rhodecode.lib import helpers as h
@@ -390,6 +390,7 b' def ApplicationVisualisationForm(localiz'
390 390 rhodecode_markup_renderer = v.OneOf(['markdown', 'rst'])
391 391 rhodecode_gravatar_url = v.UnicodeString(min=3)
392 392 rhodecode_clone_uri_tmpl = v.UnicodeString(not_empty=False, if_empty=Repository.DEFAULT_CLONE_URI)
393 rhodecode_clone_uri_id_tmpl = v.UnicodeString(not_empty=False, if_empty=Repository.DEFAULT_CLONE_URI_ID)
393 394 rhodecode_clone_uri_ssh_tmpl = v.UnicodeString(not_empty=False, if_empty=Repository.DEFAULT_CLONE_URI_SSH)
394 395 rhodecode_support_url = v.UnicodeString()
395 396 rhodecode_show_revision_number = v.StringBoolean(if_missing=False)
@@ -343,6 +343,7 b' class EmailNotificationModel(BaseModel):'
343 343 TYPE_PASSWORD_RESET_CONFIRMATION = 'password_reset_confirmation'
344 344 TYPE_EMAIL_TEST = 'email_test'
345 345 TYPE_EMAIL_EXCEPTION = 'exception'
346 TYPE_UPDATE_AVAILABLE = 'update_available'
346 347 TYPE_TEST = 'test'
347 348
348 349 email_types = {
@@ -352,6 +353,8 b' class EmailNotificationModel(BaseModel):'
352 353 'rhodecode:templates/email_templates/test.mako',
353 354 TYPE_EMAIL_EXCEPTION:
354 355 'rhodecode:templates/email_templates/exception_tracker.mako',
356 TYPE_UPDATE_AVAILABLE:
357 'rhodecode:templates/email_templates/update_available.mako',
355 358 TYPE_EMAIL_TEST:
356 359 'rhodecode:templates/email_templates/email_test.mako',
357 360 TYPE_REGISTRATION:
@@ -60,11 +60,11 b' class UpdateModel(BaseModel):'
60 60 Session().add(setting)
61 61 Session().commit()
62 62
63 def get_stored_version(self):
63 def get_stored_version(self, fallback=None):
64 64 obj = SettingsModel().get_setting_by_name(self.UPDATE_SETTINGS_KEY)
65 65 if obj:
66 66 return obj.app_settings_value
67 return '0.0.0'
67 return fallback or '0.0.0'
68 68
69 69 def _sanitize_version(self, version):
70 70 """
@@ -25,6 +25,7 b' var firefoxAnchorFix = function() {'
25 25 }
26 26 };
27 27
28
28 29 var linkifyComments = function(comments) {
29 30 var firstCommentId = null;
30 31 if (comments) {
@@ -36,6 +37,7 b' var linkifyComments = function(comments)'
36 37 }
37 38 };
38 39
40
39 41 var bindToggleButtons = function() {
40 42 $('.comment-toggle').on('click', function() {
41 43 $(this).parent().nextUntil('tr.line').toggle('inline-comments');
@@ -43,7 +45,6 b' var bindToggleButtons = function() {'
43 45 };
44 46
45 47
46
47 48 var _submitAjaxPOST = function(url, postData, successHandler, failHandler) {
48 49 failHandler = failHandler || function() {};
49 50 postData = toQueryString(postData);
@@ -63,8 +64,6 b' var _submitAjaxPOST = function(url, post'
63 64 };
64 65
65 66
66
67
68 67 /* Comment form for main and inline comments */
69 68 (function(mod) {
70 69
@@ -239,8 +238,7 b' var _submitAjaxPOST = function(url, post'
239 238 };
240 239
241 240 this.markCommentResolved = function(resolvedCommentId){
242 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolved').show();
243 $('#comment-label-{0}'.format(resolvedCommentId)).find('.resolve').hide();
241 Rhodecode.comments.markCommentResolved(resolvedCommentId)
244 242 };
245 243
246 244 this.isAllowedToSubmit = function() {
@@ -1308,6 +1306,11 b' var CommentsController = function() {'
1308 1306 return $(tmpl);
1309 1307 }
1310 1308
1309 this.markCommentResolved = function(commentId) {
1310 $('#comment-label-{0}'.format(commentId)).find('.resolved').show();
1311 $('#comment-label-{0}'.format(commentId)).find('.resolve').hide();
1312 };
1313
1311 1314 this.createComment = function(node, f_path, line_no, resolutionComment) {
1312 1315 self.edit = false;
1313 1316 var $node = $(node);
@@ -1403,7 +1406,7 b' var CommentsController = function() {'
1403 1406
1404 1407 //mark visually which comment was resolved
1405 1408 if (resolvesCommentId) {
1406 commentForm.markCommentResolved(resolvesCommentId);
1409 self.markCommentResolved(resolvesCommentId);
1407 1410 }
1408 1411
1409 1412 // run global callback on submit
@@ -1462,7 +1465,6 b' var CommentsController = function() {'
1462 1465
1463 1466 var comment = $('#comment-'+commentId);
1464 1467 var commentData = comment.data();
1465 console.log(commentData);
1466 1468
1467 1469 if (commentData.commentInline) {
1468 1470 var f_path = commentData.commentFPath;
@@ -1494,9 +1496,144 b' var CommentsController = function() {'
1494 1496 return false;
1495 1497 };
1496 1498
1499 this.resolveTodo = function (elem, todoId) {
1500 var commentId = todoId;
1501
1502 SwalNoAnimation.fire({
1503 title: 'Resolve TODO {0}'.format(todoId),
1504 showCancelButton: true,
1505 confirmButtonText: _gettext('Yes'),
1506 showLoaderOnConfirm: true,
1507
1508 allowOutsideClick: function () {
1509 !Swal.isLoading()
1510 },
1511 preConfirm: function () {
1512 var comment = $('#comment-' + commentId);
1513 var commentData = comment.data();
1514
1515 var f_path = null
1516 var line_no = null
1517 if (commentData.commentInline) {
1518 f_path = commentData.commentFPath;
1519 line_no = commentData.commentLineNo;
1520 }
1521
1522 var renderer = templateContext.visual.default_renderer;
1523 var commentBoxUrl = '{1}#comment-{0}'.format(commentId);
1524
1525 // Pull request case
1526 if (templateContext.pull_request_data.pull_request_id !== null) {
1527 var commentUrl = pyroutes.url('pullrequest_comment_create',
1528 {
1529 'repo_name': templateContext.repo_name,
1530 'pull_request_id': templateContext.pull_request_data.pull_request_id,
1531 'comment_id': commentId
1532 });
1533 } else {
1534 var commentUrl = pyroutes.url('repo_commit_comment_create',
1535 {
1536 'repo_name': templateContext.repo_name,
1537 'commit_id': templateContext.commit_data.commit_id,
1538 'comment_id': commentId
1539 });
1540 }
1541
1542 if (renderer === 'rst') {
1543 commentBoxUrl = '`#{0} <{1}#comment-{0}>`_'.format(commentId, commentUrl);
1544 } else if (renderer === 'markdown') {
1545 commentBoxUrl = '[#{0}]({1}#comment-{0})'.format(commentId, commentUrl);
1546 }
1547 var resolveText = _gettext('TODO from comment {0} was fixed.').format(commentBoxUrl);
1548
1549 var postData = {
1550 text: resolveText,
1551 comment_type: 'note',
1552 draft: false,
1553 csrf_token: CSRF_TOKEN,
1554 resolves_comment_id: commentId
1555 }
1556 if (commentData.commentInline) {
1557 postData['f_path'] = f_path;
1558 postData['line'] = line_no;
1559 }
1560
1561 return new Promise(function (resolve, reject) {
1562 $.ajax({
1563 type: 'POST',
1564 data: postData,
1565 url: commentUrl,
1566 headers: {'X-PARTIAL-XHR': true}
1567 })
1568 .done(function (data) {
1569 resolve(data);
1570 })
1571 .fail(function (jqXHR, textStatus, errorThrown) {
1572 var prefix = "Error while resolving TODO.\n"
1573 var message = formatErrorMessage(jqXHR, textStatus, errorThrown, prefix);
1574 ajaxErrorSwal(message);
1575 });
1576 })
1577 }
1578
1579 })
1580 .then(function (result) {
1581 var success = function (json_data) {
1582 resolvesCommentId = commentId;
1583 var commentResolved = json_data[Object.keys(json_data)[0]]
1584
1585 try {
1586
1587 if (commentResolved.f_path) {
1588 // inject newly created comments, json_data is {<comment_id>: {}}
1589 self.attachInlineComment(json_data)
1590 } else {
1591 self.attachGeneralComment(json_data)
1592 }
1593
1594 //mark visually which comment was resolved
1595 if (resolvesCommentId) {
1596 self.markCommentResolved(resolvesCommentId);
1597 }
1598
1599 // run global callback on submit
1600 if (window.commentFormGlobalSubmitSuccessCallback !== undefined) {
1601 commentFormGlobalSubmitSuccessCallback({
1602 draft: false,
1603 comment_id: commentId
1604 });
1605 }
1606
1607 } catch (e) {
1608 console.error(e);
1609 }
1610
1611 if (window.updateSticky !== undefined) {
1612 // potentially our comments change the active window size, so we
1613 // notify sticky elements
1614 updateSticky()
1615 }
1616
1617 if (window.refreshAllComments !== undefined) {
1618 // if we have this handler, run it, and refresh all comments boxes
1619 refreshAllComments()
1620 }
1621 // re trigger the linkification of next/prev navigation
1622 linkifyComments($('.inline-comment-injected'));
1623 timeagoActivate();
1624 tooltipActivate();
1625 };
1626
1627 if (result.value) {
1628 $(elem).remove();
1629 success(result.value)
1630 }
1631 })
1632 };
1633
1497 1634 };
1498 1635
1499 1636 window.commentHelp = function(renderer) {
1500 1637 var funcData = {'renderer': renderer}
1501 1638 return renderTemplate('commentHelpHovercard', funcData)
1502 } No newline at end of file
1639 }
@@ -174,6 +174,9 b''
174 174 ${h.text('rhodecode_clone_uri_tmpl', size=60)} HTTP[S]
175 175 </div>
176 176 <div class="field">
177 ${h.text('rhodecode_clone_uri_id_tmpl', size=60)} HTTP UID
178 </div>
179 <div class="field">
177 180 ${h.text('rhodecode_clone_uri_ssh_tmpl', size=60)} SSH
178 181 </div>
179 182 <div class="field">
@@ -236,6 +236,14 b' if (show_disabled) {'
236 236 Created:
237 237 <time class="timeago" title="<%= created_on %>" datetime="<%= datetime %>"><%= $.timeago(datetime) %></time>
238 238
239 <% if (is_todo) { %>
240 <div style="text-align: center; padding-top: 5px">
241 <a class="btn btn-sm" href="#resolveTodo<%- comment_id -%>" onclick="Rhodecode.comments.resolveTodo(this, '<%- comment_id -%>'); return false">
242 <strong>Resolve TODO</strong>
243 </a>
244 </div>
245 <% } %>
246
239 247 </div>
240 248
241 249 </script>
@@ -14,7 +14,7 b' data = {'
14 14 'comment_type': comment_type,
15 15 'comment_id': comment_id,
16 16
17 'pr_title': pull_request.title,
17 'pr_title': pull_request.title_safe,
18 18 'pr_id': pull_request.pull_request_id,
19 19 'mention_prefix': '[mention] ' if mention else '',
20 20 }
@@ -31,7 +31,6 b' else:'
31 31 _('{mention_prefix}{user} left a {comment_type} on pull request !{pr_id}: "{pr_title}"').format(**data)
32 32 %>
33 33
34
35 34 ${subject_template.format(**data) |n}
36 35 </%def>
37 36
@@ -47,7 +46,7 b' data = {'
47 46 'comment_type': comment_type,
48 47 'comment_id': comment_id,
49 48
50 'pr_title': pull_request.title,
49 'pr_title': pull_request.title_safe,
51 50 'pr_id': pull_request.pull_request_id,
52 51 'source_ref_type': pull_request.source_ref_parts.type,
53 52 'source_ref_name': pull_request.source_ref_parts.name,
@@ -99,7 +98,7 b' data = {'
99 98 'comment_id': comment_id,
100 99 'renderer_type': renderer_type or 'plain',
101 100
102 'pr_title': pull_request.title,
101 'pr_title': pull_request.title_safe,
103 102 'pr_id': pull_request.pull_request_id,
104 103 'status': status_change,
105 104 'source_ref_type': pull_request.source_ref_parts.type,
@@ -8,7 +8,7 b''
8 8 data = {
9 9 'user': '@'+h.person(user),
10 10 'pr_id': pull_request.pull_request_id,
11 'pr_title': pull_request.title,
11 'pr_title': pull_request.title_safe,
12 12 }
13 13
14 14 if user_role == 'observer':
@@ -26,7 +26,7 b' else:'
26 26 data = {
27 27 'user': h.person(user),
28 28 'pr_id': pull_request.pull_request_id,
29 'pr_title': pull_request.title,
29 'pr_title': pull_request.title_safe,
30 30 'source_ref_type': pull_request.source_ref_parts.type,
31 31 'source_ref_name': pull_request.source_ref_parts.name,
32 32 'target_ref_type': pull_request.target_ref_parts.type,
@@ -66,7 +66,7 b' data = {'
66 66 data = {
67 67 'user': h.person(user),
68 68 'pr_id': pull_request.pull_request_id,
69 'pr_title': pull_request.title,
69 'pr_title': pull_request.title_safe,
70 70 'source_ref_type': pull_request.source_ref_parts.type,
71 71 'source_ref_name': pull_request.source_ref_parts.name,
72 72 'target_ref_type': pull_request.target_ref_parts.type,
@@ -8,7 +8,7 b''
8 8 data = {
9 9 'updating_user': '@'+h.person(updating_user),
10 10 'pr_id': pull_request.pull_request_id,
11 'pr_title': pull_request.title,
11 'pr_title': pull_request.title_safe,
12 12 }
13 13
14 14 subject_template = email_pr_update_subject_template or _('{updating_user} updated pull request. !{pr_id}: "{pr_title}"')
@@ -23,7 +23,7 b' subject_template = email_pr_update_subje'
23 23 data = {
24 24 'updating_user': h.person(updating_user),
25 25 'pr_id': pull_request.pull_request_id,
26 'pr_title': pull_request.title,
26 'pr_title': pull_request.title_safe,
27 27 'source_ref_type': pull_request.source_ref_parts.type,
28 28 'source_ref_name': pull_request.source_ref_parts.name,
29 29 'target_ref_type': pull_request.target_ref_parts.type,
@@ -74,7 +74,7 b' data = {'
74 74 data = {
75 75 'updating_user': h.person(updating_user),
76 76 'pr_id': pull_request.pull_request_id,
77 'pr_title': pull_request.title,
77 'pr_title': pull_request.title_safe,
78 78 'source_ref_type': pull_request.source_ref_parts.type,
79 79 'source_ref_name': pull_request.source_ref_parts.name,
80 80 'target_ref_type': pull_request.target_ref_parts.type,
@@ -27,6 +27,16 b' from rhodecode.model.db import User, Pul'
27 27 from rhodecode.model.notification import EmailNotificationModel
28 28
29 29
30 @pytest.fixture()
31 def pr():
32 def factory(ref):
33 return collections.namedtuple(
34 'PullRequest',
35 'pull_request_id, title, title_safe, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')\
36 (200, 'Example Pull Request', 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
37 return factory
38
39
30 40 def test_get_template_obj(app, request_stub):
31 41 template = EmailNotificationModel().get_renderer(
32 42 EmailNotificationModel.TYPE_TEST, request_stub)
@@ -53,14 +63,10 b' def test_render_email(app, http_host_onl'
53 63
54 64
55 65 @pytest.mark.parametrize('role', PullRequestReviewers.ROLES)
56 def test_render_pr_email(app, user_admin, role):
66 def test_render_pr_email(app, user_admin, role, pr):
57 67 ref = collections.namedtuple(
58 68 'Ref', 'name, type')('fxies123', 'book')
59
60 pr = collections.namedtuple('PullRequest',
61 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
62 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
63
69 pr = pr(ref)
64 70 source_repo = target_repo = collections.namedtuple(
65 71 'Repo', 'type, repo_name')('hg', 'pull_request_1')
66 72
@@ -89,13 +95,11 b' def test_render_pr_email(app, user_admin'
89 95 assert subject == '@test_admin (RhodeCode Admin) added you as observer to pull request. !200: "Example Pull Request"'
90 96
91 97
92 def test_render_pr_update_email(app, user_admin):
98 def test_render_pr_update_email(app, user_admin, pr):
93 99 ref = collections.namedtuple(
94 100 'Ref', 'name, type')('fxies123', 'book')
95 101
96 pr = collections.namedtuple('PullRequest',
97 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
98 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
102 pr = pr(ref)
99 103
100 104 source_repo = target_repo = collections.namedtuple(
101 105 'Repo', 'type, repo_name')('hg', 'pull_request_1')
@@ -150,13 +154,11 b' def test_render_pr_update_email(app, use'
150 154 EmailNotificationModel.TYPE_COMMIT_COMMENT,
151 155 EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
152 156 ])
153 def test_render_comment_subject_no_newlines(app, mention, email_type):
157 def test_render_comment_subject_no_newlines(app, mention, email_type, pr):
154 158 ref = collections.namedtuple(
155 159 'Ref', 'name, type')('fxies123', 'book')
156 160
157 pr = collections.namedtuple('PullRequest',
158 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
159 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
161 pr = pr(ref)
160 162
161 163 source_repo = target_repo = collections.namedtuple(
162 164 'Repo', 'type, repo_name')('hg', 'pull_request_1')
General Comments 0
You need to be logged in to leave comments. Login now