##// END OF EJS Templates
cleanup: trivial fixes for some pyflakes warnings
Mads Kiilerich -
r8107:27c4ad3e default
parent child Browse files
Show More
@@ -1,490 +1,490 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.controllers.changeset
15 kallithea.controllers.changeset
16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17
17
18 changeset controller showing changes between revisions
18 changeset controller showing changes between revisions
19
19
20 This file was forked by the Kallithea project in July 2014.
20 This file was forked by the Kallithea project in July 2014.
21 Original author and date, and relevant copyright and licensing information is below:
21 Original author and date, and relevant copyright and licensing information is below:
22 :created_on: Apr 25, 2010
22 :created_on: Apr 25, 2010
23 :author: marcink
23 :author: marcink
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 :license: GPLv3, see LICENSE.md for more details.
25 :license: GPLv3, see LICENSE.md for more details.
26 """
26 """
27
27
28 import binascii
28 import binascii
29 import logging
29 import logging
30 import traceback
30 import traceback
31 from collections import OrderedDict, defaultdict
31 from collections import OrderedDict, defaultdict
32
32
33 from tg import request, response
33 from tg import request, response
34 from tg import tmpl_context as c
34 from tg import tmpl_context as c
35 from tg.i18n import ugettext as _
35 from tg.i18n import ugettext as _
36 from webob.exc import HTTPBadRequest, HTTPForbidden, HTTPFound, HTTPNotFound
36 from webob.exc import HTTPBadRequest, HTTPForbidden, HTTPNotFound
37
37
38 import kallithea.lib.helpers as h
38 import kallithea.lib.helpers as h
39 from kallithea.lib import diffs
39 from kallithea.lib import diffs
40 from kallithea.lib.auth import HasRepoPermissionLevelDecorator, LoginRequired
40 from kallithea.lib.auth import HasRepoPermissionLevelDecorator, LoginRequired
41 from kallithea.lib.base import BaseRepoController, jsonify, render
41 from kallithea.lib.base import BaseRepoController, jsonify, render
42 from kallithea.lib.graphmod import graph_data
42 from kallithea.lib.graphmod import graph_data
43 from kallithea.lib.utils import action_logger
43 from kallithea.lib.utils import action_logger
44 from kallithea.lib.utils2 import ascii_str, safe_str
44 from kallithea.lib.utils2 import ascii_str, safe_str
45 from kallithea.lib.vcs.backends.base import EmptyChangeset
45 from kallithea.lib.vcs.backends.base import EmptyChangeset
46 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError
46 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError
47 from kallithea.model.changeset_status import ChangesetStatusModel
47 from kallithea.model.changeset_status import ChangesetStatusModel
48 from kallithea.model.comment import ChangesetCommentsModel
48 from kallithea.model.comment import ChangesetCommentsModel
49 from kallithea.model.db import ChangesetComment, ChangesetStatus
49 from kallithea.model.db import ChangesetComment, ChangesetStatus
50 from kallithea.model.meta import Session
50 from kallithea.model.meta import Session
51 from kallithea.model.pull_request import PullRequestModel
51 from kallithea.model.pull_request import PullRequestModel
52
52
53
53
54 log = logging.getLogger(__name__)
54 log = logging.getLogger(__name__)
55
55
56
56
57 def _update_with_GET(params, GET):
57 def _update_with_GET(params, GET):
58 for k in ['diff1', 'diff2', 'diff']:
58 for k in ['diff1', 'diff2', 'diff']:
59 params[k] += GET.getall(k)
59 params[k] += GET.getall(k)
60
60
61
61
62 def anchor_url(revision, path, GET):
62 def anchor_url(revision, path, GET):
63 fid = h.FID(revision, path)
63 fid = h.FID(revision, path)
64 return h.url.current(anchor=fid, **dict(GET))
64 return h.url.current(anchor=fid, **dict(GET))
65
65
66
66
67 def get_ignore_ws(fid, GET):
67 def get_ignore_ws(fid, GET):
68 ig_ws_global = GET.get('ignorews')
68 ig_ws_global = GET.get('ignorews')
69 ig_ws = [k for k in GET.getall(fid) if k.startswith('WS')]
69 ig_ws = [k for k in GET.getall(fid) if k.startswith('WS')]
70 if ig_ws:
70 if ig_ws:
71 try:
71 try:
72 return int(ig_ws[0].split(':')[-1])
72 return int(ig_ws[0].split(':')[-1])
73 except ValueError:
73 except ValueError:
74 raise HTTPBadRequest()
74 raise HTTPBadRequest()
75 return ig_ws_global
75 return ig_ws_global
76
76
77
77
78 def _ignorews_url(GET, fileid=None):
78 def _ignorews_url(GET, fileid=None):
79 fileid = str(fileid) if fileid else None
79 fileid = str(fileid) if fileid else None
80 params = defaultdict(list)
80 params = defaultdict(list)
81 _update_with_GET(params, GET)
81 _update_with_GET(params, GET)
82 lbl = _('Show whitespace')
82 lbl = _('Show whitespace')
83 ig_ws = get_ignore_ws(fileid, GET)
83 ig_ws = get_ignore_ws(fileid, GET)
84 ln_ctx = get_line_ctx(fileid, GET)
84 ln_ctx = get_line_ctx(fileid, GET)
85 # global option
85 # global option
86 if fileid is None:
86 if fileid is None:
87 if ig_ws is None:
87 if ig_ws is None:
88 params['ignorews'] += [1]
88 params['ignorews'] += [1]
89 lbl = _('Ignore whitespace')
89 lbl = _('Ignore whitespace')
90 ctx_key = 'context'
90 ctx_key = 'context'
91 ctx_val = ln_ctx
91 ctx_val = ln_ctx
92 # per file options
92 # per file options
93 else:
93 else:
94 if ig_ws is None:
94 if ig_ws is None:
95 params[fileid] += ['WS:1']
95 params[fileid] += ['WS:1']
96 lbl = _('Ignore whitespace')
96 lbl = _('Ignore whitespace')
97
97
98 ctx_key = fileid
98 ctx_key = fileid
99 ctx_val = 'C:%s' % ln_ctx
99 ctx_val = 'C:%s' % ln_ctx
100 # if we have passed in ln_ctx pass it along to our params
100 # if we have passed in ln_ctx pass it along to our params
101 if ln_ctx:
101 if ln_ctx:
102 params[ctx_key] += [ctx_val]
102 params[ctx_key] += [ctx_val]
103
103
104 params['anchor'] = fileid
104 params['anchor'] = fileid
105 icon = h.literal('<i class="icon-strike"></i>')
105 icon = h.literal('<i class="icon-strike"></i>')
106 return h.link_to(icon, h.url.current(**params), title=lbl, **{'data-toggle': 'tooltip'})
106 return h.link_to(icon, h.url.current(**params), title=lbl, **{'data-toggle': 'tooltip'})
107
107
108
108
109 def get_line_ctx(fid, GET):
109 def get_line_ctx(fid, GET):
110 ln_ctx_global = GET.get('context')
110 ln_ctx_global = GET.get('context')
111 if fid:
111 if fid:
112 ln_ctx = [k for k in GET.getall(fid) if k.startswith('C')]
112 ln_ctx = [k for k in GET.getall(fid) if k.startswith('C')]
113 else:
113 else:
114 _ln_ctx = [k for k in GET if k.startswith('C')]
114 _ln_ctx = [k for k in GET if k.startswith('C')]
115 ln_ctx = GET.get(_ln_ctx[0]) if _ln_ctx else ln_ctx_global
115 ln_ctx = GET.get(_ln_ctx[0]) if _ln_ctx else ln_ctx_global
116 if ln_ctx:
116 if ln_ctx:
117 ln_ctx = [ln_ctx]
117 ln_ctx = [ln_ctx]
118
118
119 if ln_ctx:
119 if ln_ctx:
120 retval = ln_ctx[0].split(':')[-1]
120 retval = ln_ctx[0].split(':')[-1]
121 else:
121 else:
122 retval = ln_ctx_global
122 retval = ln_ctx_global
123
123
124 try:
124 try:
125 return int(retval)
125 return int(retval)
126 except Exception:
126 except Exception:
127 return 3
127 return 3
128
128
129
129
130 def _context_url(GET, fileid=None):
130 def _context_url(GET, fileid=None):
131 """
131 """
132 Generates url for context lines
132 Generates url for context lines
133
133
134 :param fileid:
134 :param fileid:
135 """
135 """
136
136
137 fileid = str(fileid) if fileid else None
137 fileid = str(fileid) if fileid else None
138 ig_ws = get_ignore_ws(fileid, GET)
138 ig_ws = get_ignore_ws(fileid, GET)
139 ln_ctx = (get_line_ctx(fileid, GET) or 3) * 2
139 ln_ctx = (get_line_ctx(fileid, GET) or 3) * 2
140
140
141 params = defaultdict(list)
141 params = defaultdict(list)
142 _update_with_GET(params, GET)
142 _update_with_GET(params, GET)
143
143
144 # global option
144 # global option
145 if fileid is None:
145 if fileid is None:
146 if ln_ctx > 0:
146 if ln_ctx > 0:
147 params['context'] += [ln_ctx]
147 params['context'] += [ln_ctx]
148
148
149 if ig_ws:
149 if ig_ws:
150 ig_ws_key = 'ignorews'
150 ig_ws_key = 'ignorews'
151 ig_ws_val = 1
151 ig_ws_val = 1
152
152
153 # per file option
153 # per file option
154 else:
154 else:
155 params[fileid] += ['C:%s' % ln_ctx]
155 params[fileid] += ['C:%s' % ln_ctx]
156 ig_ws_key = fileid
156 ig_ws_key = fileid
157 ig_ws_val = 'WS:%s' % 1
157 ig_ws_val = 'WS:%s' % 1
158
158
159 if ig_ws:
159 if ig_ws:
160 params[ig_ws_key] += [ig_ws_val]
160 params[ig_ws_key] += [ig_ws_val]
161
161
162 lbl = _('Increase diff context to %(num)s lines') % {'num': ln_ctx}
162 lbl = _('Increase diff context to %(num)s lines') % {'num': ln_ctx}
163
163
164 params['anchor'] = fileid
164 params['anchor'] = fileid
165 icon = h.literal('<i class="icon-sort"></i>')
165 icon = h.literal('<i class="icon-sort"></i>')
166 return h.link_to(icon, h.url.current(**params), title=lbl, **{'data-toggle': 'tooltip'})
166 return h.link_to(icon, h.url.current(**params), title=lbl, **{'data-toggle': 'tooltip'})
167
167
168
168
169 def create_cs_pr_comment(repo_name, revision=None, pull_request=None, allowed_to_change_status=True):
169 def create_cs_pr_comment(repo_name, revision=None, pull_request=None, allowed_to_change_status=True):
170 """
170 """
171 Add a comment to the specified changeset or pull request, using POST values
171 Add a comment to the specified changeset or pull request, using POST values
172 from the request.
172 from the request.
173
173
174 Comments can be inline (when a file path and line number is specified in
174 Comments can be inline (when a file path and line number is specified in
175 POST) or general comments.
175 POST) or general comments.
176 A comment can be accompanied by a review status change (accepted, rejected,
176 A comment can be accompanied by a review status change (accepted, rejected,
177 etc.). Pull requests can be closed or deleted.
177 etc.). Pull requests can be closed or deleted.
178
178
179 Parameter 'allowed_to_change_status' is used for both status changes and
179 Parameter 'allowed_to_change_status' is used for both status changes and
180 closing of pull requests. For deleting of pull requests, more specific
180 closing of pull requests. For deleting of pull requests, more specific
181 checks are done.
181 checks are done.
182 """
182 """
183
183
184 assert request.environ.get('HTTP_X_PARTIAL_XHR')
184 assert request.environ.get('HTTP_X_PARTIAL_XHR')
185 if pull_request:
185 if pull_request:
186 pull_request_id = pull_request.pull_request_id
186 pull_request_id = pull_request.pull_request_id
187 else:
187 else:
188 pull_request_id = None
188 pull_request_id = None
189
189
190 status = request.POST.get('changeset_status')
190 status = request.POST.get('changeset_status')
191 close_pr = request.POST.get('save_close')
191 close_pr = request.POST.get('save_close')
192 delete = request.POST.get('save_delete')
192 delete = request.POST.get('save_delete')
193 f_path = request.POST.get('f_path')
193 f_path = request.POST.get('f_path')
194 line_no = request.POST.get('line')
194 line_no = request.POST.get('line')
195
195
196 if (status or close_pr or delete) and (f_path or line_no):
196 if (status or close_pr or delete) and (f_path or line_no):
197 # status votes and closing is only possible in general comments
197 # status votes and closing is only possible in general comments
198 raise HTTPBadRequest()
198 raise HTTPBadRequest()
199
199
200 if not allowed_to_change_status:
200 if not allowed_to_change_status:
201 if status or close_pr:
201 if status or close_pr:
202 h.flash(_('No permission to change status'), 'error')
202 h.flash(_('No permission to change status'), 'error')
203 raise HTTPForbidden()
203 raise HTTPForbidden()
204
204
205 if pull_request and delete == "delete":
205 if pull_request and delete == "delete":
206 if (pull_request.owner_id == request.authuser.user_id or
206 if (pull_request.owner_id == request.authuser.user_id or
207 h.HasPermissionAny('hg.admin')() or
207 h.HasPermissionAny('hg.admin')() or
208 h.HasRepoPermissionLevel('admin')(pull_request.org_repo.repo_name) or
208 h.HasRepoPermissionLevel('admin')(pull_request.org_repo.repo_name) or
209 h.HasRepoPermissionLevel('admin')(pull_request.other_repo.repo_name)
209 h.HasRepoPermissionLevel('admin')(pull_request.other_repo.repo_name)
210 ) and not pull_request.is_closed():
210 ) and not pull_request.is_closed():
211 PullRequestModel().delete(pull_request)
211 PullRequestModel().delete(pull_request)
212 Session().commit()
212 Session().commit()
213 h.flash(_('Successfully deleted pull request %s') % pull_request_id,
213 h.flash(_('Successfully deleted pull request %s') % pull_request_id,
214 category='success')
214 category='success')
215 return {
215 return {
216 'location': h.url('my_pullrequests'), # or repo pr list?
216 'location': h.url('my_pullrequests'), # or repo pr list?
217 }
217 }
218 raise HTTPForbidden()
218 raise HTTPForbidden()
219
219
220 text = request.POST.get('text', '').strip()
220 text = request.POST.get('text', '').strip()
221
221
222 comment = ChangesetCommentsModel().create(
222 comment = ChangesetCommentsModel().create(
223 text=text,
223 text=text,
224 repo=c.db_repo.repo_id,
224 repo=c.db_repo.repo_id,
225 author=request.authuser.user_id,
225 author=request.authuser.user_id,
226 revision=revision,
226 revision=revision,
227 pull_request=pull_request_id,
227 pull_request=pull_request_id,
228 f_path=f_path or None,
228 f_path=f_path or None,
229 line_no=line_no or None,
229 line_no=line_no or None,
230 status_change=ChangesetStatus.get_status_lbl(status) if status else None,
230 status_change=ChangesetStatus.get_status_lbl(status) if status else None,
231 closing_pr=close_pr,
231 closing_pr=close_pr,
232 )
232 )
233
233
234 if status:
234 if status:
235 ChangesetStatusModel().set_status(
235 ChangesetStatusModel().set_status(
236 c.db_repo.repo_id,
236 c.db_repo.repo_id,
237 status,
237 status,
238 request.authuser.user_id,
238 request.authuser.user_id,
239 comment,
239 comment,
240 revision=revision,
240 revision=revision,
241 pull_request=pull_request_id,
241 pull_request=pull_request_id,
242 )
242 )
243
243
244 if pull_request:
244 if pull_request:
245 action = 'user_commented_pull_request:%s' % pull_request_id
245 action = 'user_commented_pull_request:%s' % pull_request_id
246 else:
246 else:
247 action = 'user_commented_revision:%s' % revision
247 action = 'user_commented_revision:%s' % revision
248 action_logger(request.authuser, action, c.db_repo, request.ip_addr)
248 action_logger(request.authuser, action, c.db_repo, request.ip_addr)
249
249
250 if pull_request and close_pr:
250 if pull_request and close_pr:
251 PullRequestModel().close_pull_request(pull_request_id)
251 PullRequestModel().close_pull_request(pull_request_id)
252 action_logger(request.authuser,
252 action_logger(request.authuser,
253 'user_closed_pull_request:%s' % pull_request_id,
253 'user_closed_pull_request:%s' % pull_request_id,
254 c.db_repo, request.ip_addr)
254 c.db_repo, request.ip_addr)
255
255
256 Session().commit()
256 Session().commit()
257
257
258 data = {
258 data = {
259 'target_id': h.safeid(request.POST.get('f_path')),
259 'target_id': h.safeid(request.POST.get('f_path')),
260 }
260 }
261 if comment is not None:
261 if comment is not None:
262 c.comment = comment
262 c.comment = comment
263 data.update(comment.get_dict())
263 data.update(comment.get_dict())
264 data.update({'rendered_text':
264 data.update({'rendered_text':
265 render('changeset/changeset_comment_block.html')})
265 render('changeset/changeset_comment_block.html')})
266
266
267 return data
267 return data
268
268
269 def delete_cs_pr_comment(repo_name, comment_id):
269 def delete_cs_pr_comment(repo_name, comment_id):
270 """Delete a comment from a changeset or pull request"""
270 """Delete a comment from a changeset or pull request"""
271 co = ChangesetComment.get_or_404(comment_id)
271 co = ChangesetComment.get_or_404(comment_id)
272 if co.repo.repo_name != repo_name:
272 if co.repo.repo_name != repo_name:
273 raise HTTPNotFound()
273 raise HTTPNotFound()
274 if co.pull_request and co.pull_request.is_closed():
274 if co.pull_request and co.pull_request.is_closed():
275 # don't allow deleting comments on closed pull request
275 # don't allow deleting comments on closed pull request
276 raise HTTPForbidden()
276 raise HTTPForbidden()
277
277
278 owner = co.author_id == request.authuser.user_id
278 owner = co.author_id == request.authuser.user_id
279 repo_admin = h.HasRepoPermissionLevel('admin')(repo_name)
279 repo_admin = h.HasRepoPermissionLevel('admin')(repo_name)
280 if h.HasPermissionAny('hg.admin')() or repo_admin or owner:
280 if h.HasPermissionAny('hg.admin')() or repo_admin or owner:
281 ChangesetCommentsModel().delete(comment=co)
281 ChangesetCommentsModel().delete(comment=co)
282 Session().commit()
282 Session().commit()
283 return True
283 return True
284 else:
284 else:
285 raise HTTPForbidden()
285 raise HTTPForbidden()
286
286
287 class ChangesetController(BaseRepoController):
287 class ChangesetController(BaseRepoController):
288
288
289 def _before(self, *args, **kwargs):
289 def _before(self, *args, **kwargs):
290 super(ChangesetController, self)._before(*args, **kwargs)
290 super(ChangesetController, self)._before(*args, **kwargs)
291 c.affected_files_cut_off = 60
291 c.affected_files_cut_off = 60
292
292
293 def _index(self, revision, method):
293 def _index(self, revision, method):
294 c.pull_request = None
294 c.pull_request = None
295 c.anchor_url = anchor_url
295 c.anchor_url = anchor_url
296 c.ignorews_url = _ignorews_url
296 c.ignorews_url = _ignorews_url
297 c.context_url = _context_url
297 c.context_url = _context_url
298 c.fulldiff = request.GET.get('fulldiff') # for reporting number of changed files
298 c.fulldiff = request.GET.get('fulldiff') # for reporting number of changed files
299 # get ranges of revisions if preset
299 # get ranges of revisions if preset
300 rev_range = revision.split('...')[:2]
300 rev_range = revision.split('...')[:2]
301 enable_comments = True
301 enable_comments = True
302 c.cs_repo = c.db_repo
302 c.cs_repo = c.db_repo
303 try:
303 try:
304 if len(rev_range) == 2:
304 if len(rev_range) == 2:
305 enable_comments = False
305 enable_comments = False
306 rev_start = rev_range[0]
306 rev_start = rev_range[0]
307 rev_end = rev_range[1]
307 rev_end = rev_range[1]
308 rev_ranges = c.db_repo_scm_instance.get_changesets(start=rev_start,
308 rev_ranges = c.db_repo_scm_instance.get_changesets(start=rev_start,
309 end=rev_end)
309 end=rev_end)
310 else:
310 else:
311 rev_ranges = [c.db_repo_scm_instance.get_changeset(revision)]
311 rev_ranges = [c.db_repo_scm_instance.get_changeset(revision)]
312
312
313 c.cs_ranges = list(rev_ranges)
313 c.cs_ranges = list(rev_ranges)
314 if not c.cs_ranges:
314 if not c.cs_ranges:
315 raise RepositoryError('Changeset range returned empty result')
315 raise RepositoryError('Changeset range returned empty result')
316
316
317 except (ChangesetDoesNotExistError, EmptyRepositoryError):
317 except (ChangesetDoesNotExistError, EmptyRepositoryError):
318 log.debug(traceback.format_exc())
318 log.debug(traceback.format_exc())
319 msg = _('Such revision does not exist for this repository')
319 msg = _('Such revision does not exist for this repository')
320 h.flash(msg, category='error')
320 h.flash(msg, category='error')
321 raise HTTPNotFound()
321 raise HTTPNotFound()
322
322
323 c.changes = OrderedDict()
323 c.changes = OrderedDict()
324
324
325 c.lines_added = 0 # count of lines added
325 c.lines_added = 0 # count of lines added
326 c.lines_deleted = 0 # count of lines removes
326 c.lines_deleted = 0 # count of lines removes
327
327
328 c.changeset_statuses = ChangesetStatus.STATUSES
328 c.changeset_statuses = ChangesetStatus.STATUSES
329 comments = dict()
329 comments = dict()
330 c.statuses = []
330 c.statuses = []
331 c.inline_comments = []
331 c.inline_comments = []
332 c.inline_cnt = 0
332 c.inline_cnt = 0
333
333
334 # Iterate over ranges (default changeset view is always one changeset)
334 # Iterate over ranges (default changeset view is always one changeset)
335 for changeset in c.cs_ranges:
335 for changeset in c.cs_ranges:
336 if method == 'show':
336 if method == 'show':
337 c.statuses.extend([ChangesetStatusModel().get_status(
337 c.statuses.extend([ChangesetStatusModel().get_status(
338 c.db_repo.repo_id, changeset.raw_id)])
338 c.db_repo.repo_id, changeset.raw_id)])
339
339
340 # Changeset comments
340 # Changeset comments
341 comments.update((com.comment_id, com)
341 comments.update((com.comment_id, com)
342 for com in ChangesetCommentsModel()
342 for com in ChangesetCommentsModel()
343 .get_comments(c.db_repo.repo_id,
343 .get_comments(c.db_repo.repo_id,
344 revision=changeset.raw_id))
344 revision=changeset.raw_id))
345
345
346 # Status change comments - mostly from pull requests
346 # Status change comments - mostly from pull requests
347 comments.update((st.comment_id, st.comment)
347 comments.update((st.comment_id, st.comment)
348 for st in ChangesetStatusModel()
348 for st in ChangesetStatusModel()
349 .get_statuses(c.db_repo.repo_id,
349 .get_statuses(c.db_repo.repo_id,
350 changeset.raw_id, with_revisions=True)
350 changeset.raw_id, with_revisions=True)
351 if st.comment_id is not None)
351 if st.comment_id is not None)
352
352
353 inlines = ChangesetCommentsModel() \
353 inlines = ChangesetCommentsModel() \
354 .get_inline_comments(c.db_repo.repo_id,
354 .get_inline_comments(c.db_repo.repo_id,
355 revision=changeset.raw_id)
355 revision=changeset.raw_id)
356 c.inline_comments.extend(inlines)
356 c.inline_comments.extend(inlines)
357
357
358 cs2 = changeset.raw_id
358 cs2 = changeset.raw_id
359 cs1 = changeset.parents[0].raw_id if changeset.parents else EmptyChangeset().raw_id
359 cs1 = changeset.parents[0].raw_id if changeset.parents else EmptyChangeset().raw_id
360 context_lcl = get_line_ctx('', request.GET)
360 context_lcl = get_line_ctx('', request.GET)
361 ign_whitespace_lcl = get_ignore_ws('', request.GET)
361 ign_whitespace_lcl = get_ignore_ws('', request.GET)
362
362
363 raw_diff = diffs.get_diff(c.db_repo_scm_instance, cs1, cs2,
363 raw_diff = diffs.get_diff(c.db_repo_scm_instance, cs1, cs2,
364 ignore_whitespace=ign_whitespace_lcl, context=context_lcl)
364 ignore_whitespace=ign_whitespace_lcl, context=context_lcl)
365 diff_limit = None if c.fulldiff else self.cut_off_limit
365 diff_limit = None if c.fulldiff else self.cut_off_limit
366 file_diff_data = []
366 file_diff_data = []
367 if method == 'show':
367 if method == 'show':
368 diff_processor = diffs.DiffProcessor(raw_diff,
368 diff_processor = diffs.DiffProcessor(raw_diff,
369 vcs=c.db_repo_scm_instance.alias,
369 vcs=c.db_repo_scm_instance.alias,
370 diff_limit=diff_limit)
370 diff_limit=diff_limit)
371 c.limited_diff = diff_processor.limited_diff
371 c.limited_diff = diff_processor.limited_diff
372 for f in diff_processor.parsed:
372 for f in diff_processor.parsed:
373 st = f['stats']
373 st = f['stats']
374 c.lines_added += st['added']
374 c.lines_added += st['added']
375 c.lines_deleted += st['deleted']
375 c.lines_deleted += st['deleted']
376 filename = f['filename']
376 filename = f['filename']
377 fid = h.FID(changeset.raw_id, filename)
377 fid = h.FID(changeset.raw_id, filename)
378 url_fid = h.FID('', filename)
378 url_fid = h.FID('', filename)
379 html_diff = diffs.as_html(enable_comments=enable_comments, parsed_lines=[f])
379 html_diff = diffs.as_html(enable_comments=enable_comments, parsed_lines=[f])
380 file_diff_data.append((fid, url_fid, f['operation'], f['old_filename'], filename, html_diff, st))
380 file_diff_data.append((fid, url_fid, f['operation'], f['old_filename'], filename, html_diff, st))
381 else:
381 else:
382 # downloads/raw we only need RAW diff nothing else
382 # downloads/raw we only need RAW diff nothing else
383 file_diff_data.append(('', None, None, None, raw_diff, None))
383 file_diff_data.append(('', None, None, None, raw_diff, None))
384 c.changes[changeset.raw_id] = (cs1, cs2, file_diff_data)
384 c.changes[changeset.raw_id] = (cs1, cs2, file_diff_data)
385
385
386 # sort comments in creation order
386 # sort comments in creation order
387 c.comments = [com for com_id, com in sorted(comments.items())]
387 c.comments = [com for com_id, com in sorted(comments.items())]
388
388
389 # count inline comments
389 # count inline comments
390 for __, lines in c.inline_comments:
390 for __, lines in c.inline_comments:
391 for comments in lines.values():
391 for comments in lines.values():
392 c.inline_cnt += len(comments)
392 c.inline_cnt += len(comments)
393
393
394 if len(c.cs_ranges) == 1:
394 if len(c.cs_ranges) == 1:
395 c.changeset = c.cs_ranges[0]
395 c.changeset = c.cs_ranges[0]
396 c.parent_tmpl = ''.join(['# Parent %s\n' % x.raw_id
396 c.parent_tmpl = ''.join(['# Parent %s\n' % x.raw_id
397 for x in c.changeset.parents])
397 for x in c.changeset.parents])
398 c.changeset_graft_source_hash = ascii_str(c.changeset.extra.get(b'source', b''))
398 c.changeset_graft_source_hash = ascii_str(c.changeset.extra.get(b'source', b''))
399 c.changeset_transplant_source_hash = ascii_str(binascii.hexlify(c.changeset.extra.get(b'transplant_source', b'')))
399 c.changeset_transplant_source_hash = ascii_str(binascii.hexlify(c.changeset.extra.get(b'transplant_source', b'')))
400 if method == 'download':
400 if method == 'download':
401 response.content_type = 'text/plain'
401 response.content_type = 'text/plain'
402 response.content_disposition = 'attachment; filename=%s.diff' \
402 response.content_disposition = 'attachment; filename=%s.diff' \
403 % revision[:12]
403 % revision[:12]
404 return raw_diff
404 return raw_diff
405 elif method == 'patch':
405 elif method == 'patch':
406 response.content_type = 'text/plain'
406 response.content_type = 'text/plain'
407 c.diff = safe_str(raw_diff)
407 c.diff = safe_str(raw_diff)
408 return render('changeset/patch_changeset.html')
408 return render('changeset/patch_changeset.html')
409 elif method == 'raw':
409 elif method == 'raw':
410 response.content_type = 'text/plain'
410 response.content_type = 'text/plain'
411 return raw_diff
411 return raw_diff
412 elif method == 'show':
412 elif method == 'show':
413 if len(c.cs_ranges) == 1:
413 if len(c.cs_ranges) == 1:
414 return render('changeset/changeset.html')
414 return render('changeset/changeset.html')
415 else:
415 else:
416 c.cs_ranges_org = None
416 c.cs_ranges_org = None
417 c.cs_comments = {}
417 c.cs_comments = {}
418 revs = [ctx.revision for ctx in reversed(c.cs_ranges)]
418 revs = [ctx.revision for ctx in reversed(c.cs_ranges)]
419 c.jsdata = graph_data(c.db_repo_scm_instance, revs)
419 c.jsdata = graph_data(c.db_repo_scm_instance, revs)
420 return render('changeset/changeset_range.html')
420 return render('changeset/changeset_range.html')
421
421
422 @LoginRequired(allow_default_user=True)
422 @LoginRequired(allow_default_user=True)
423 @HasRepoPermissionLevelDecorator('read')
423 @HasRepoPermissionLevelDecorator('read')
424 def index(self, revision, method='show'):
424 def index(self, revision, method='show'):
425 return self._index(revision, method=method)
425 return self._index(revision, method=method)
426
426
427 @LoginRequired(allow_default_user=True)
427 @LoginRequired(allow_default_user=True)
428 @HasRepoPermissionLevelDecorator('read')
428 @HasRepoPermissionLevelDecorator('read')
429 def changeset_raw(self, revision):
429 def changeset_raw(self, revision):
430 return self._index(revision, method='raw')
430 return self._index(revision, method='raw')
431
431
432 @LoginRequired(allow_default_user=True)
432 @LoginRequired(allow_default_user=True)
433 @HasRepoPermissionLevelDecorator('read')
433 @HasRepoPermissionLevelDecorator('read')
434 def changeset_patch(self, revision):
434 def changeset_patch(self, revision):
435 return self._index(revision, method='patch')
435 return self._index(revision, method='patch')
436
436
437 @LoginRequired(allow_default_user=True)
437 @LoginRequired(allow_default_user=True)
438 @HasRepoPermissionLevelDecorator('read')
438 @HasRepoPermissionLevelDecorator('read')
439 def changeset_download(self, revision):
439 def changeset_download(self, revision):
440 return self._index(revision, method='download')
440 return self._index(revision, method='download')
441
441
442 @LoginRequired()
442 @LoginRequired()
443 @HasRepoPermissionLevelDecorator('read')
443 @HasRepoPermissionLevelDecorator('read')
444 @jsonify
444 @jsonify
445 def comment(self, repo_name, revision):
445 def comment(self, repo_name, revision):
446 return create_cs_pr_comment(repo_name, revision=revision)
446 return create_cs_pr_comment(repo_name, revision=revision)
447
447
448 @LoginRequired()
448 @LoginRequired()
449 @HasRepoPermissionLevelDecorator('read')
449 @HasRepoPermissionLevelDecorator('read')
450 @jsonify
450 @jsonify
451 def delete_comment(self, repo_name, comment_id):
451 def delete_comment(self, repo_name, comment_id):
452 return delete_cs_pr_comment(repo_name, comment_id)
452 return delete_cs_pr_comment(repo_name, comment_id)
453
453
454 @LoginRequired(allow_default_user=True)
454 @LoginRequired(allow_default_user=True)
455 @HasRepoPermissionLevelDecorator('read')
455 @HasRepoPermissionLevelDecorator('read')
456 @jsonify
456 @jsonify
457 def changeset_info(self, repo_name, revision):
457 def changeset_info(self, repo_name, revision):
458 if request.is_xhr:
458 if request.is_xhr:
459 try:
459 try:
460 return c.db_repo_scm_instance.get_changeset(revision)
460 return c.db_repo_scm_instance.get_changeset(revision)
461 except ChangesetDoesNotExistError as e:
461 except ChangesetDoesNotExistError as e:
462 return EmptyChangeset(message=str(e))
462 return EmptyChangeset(message=str(e))
463 else:
463 else:
464 raise HTTPBadRequest()
464 raise HTTPBadRequest()
465
465
466 @LoginRequired(allow_default_user=True)
466 @LoginRequired(allow_default_user=True)
467 @HasRepoPermissionLevelDecorator('read')
467 @HasRepoPermissionLevelDecorator('read')
468 @jsonify
468 @jsonify
469 def changeset_children(self, repo_name, revision):
469 def changeset_children(self, repo_name, revision):
470 if request.is_xhr:
470 if request.is_xhr:
471 changeset = c.db_repo_scm_instance.get_changeset(revision)
471 changeset = c.db_repo_scm_instance.get_changeset(revision)
472 result = {"results": []}
472 result = {"results": []}
473 if changeset.children:
473 if changeset.children:
474 result = {"results": changeset.children}
474 result = {"results": changeset.children}
475 return result
475 return result
476 else:
476 else:
477 raise HTTPBadRequest()
477 raise HTTPBadRequest()
478
478
479 @LoginRequired(allow_default_user=True)
479 @LoginRequired(allow_default_user=True)
480 @HasRepoPermissionLevelDecorator('read')
480 @HasRepoPermissionLevelDecorator('read')
481 @jsonify
481 @jsonify
482 def changeset_parents(self, repo_name, revision):
482 def changeset_parents(self, repo_name, revision):
483 if request.is_xhr:
483 if request.is_xhr:
484 changeset = c.db_repo_scm_instance.get_changeset(revision)
484 changeset = c.db_repo_scm_instance.get_changeset(revision)
485 result = {"results": []}
485 result = {"results": []}
486 if changeset.parents:
486 if changeset.parents:
487 result = {"results": changeset.parents}
487 result = {"results": changeset.parents}
488 return result
488 return result
489 else:
489 else:
490 raise HTTPBadRequest()
490 raise HTTPBadRequest()
@@ -1,21 +1,15 b''
1 from kallithea.lib.vcs.exceptions import VCSError
2
3
4 def import_class(class_path):
1 def import_class(class_path):
5 """
2 """
6 Returns class from the given path.
3 Returns class from the given path.
7
4
8 For example, in order to get class located at
5 For example, in order to get class located at
9 ``vcs.backends.hg.MercurialRepository``:
6 ``vcs.backends.hg.MercurialRepository``:
10
7
11 try:
12 hgrepo = import_class('vcs.backends.hg.MercurialRepository')
8 hgrepo = import_class('vcs.backends.hg.MercurialRepository')
13 except VCSError:
14 # handle error
15 """
9 """
16 splitted = class_path.split('.')
10 splitted = class_path.split('.')
17 mod_path = '.'.join(splitted[:-1])
11 mod_path = '.'.join(splitted[:-1])
18 class_name = splitted[-1]
12 class_name = splitted[-1]
19 class_mod = __import__(mod_path, {}, {}, [class_name])
13 class_mod = __import__(mod_path, {}, {}, [class_name])
20 cls = getattr(class_mod, class_name)
14 cls = getattr(class_mod, class_name)
21 return cls
15 return cls
@@ -1,162 +1,162 b''
1 #!/usr/bin/env python3
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
2 # -*- coding: utf-8 -*-
3 import os
3 import os
4 import platform
4 import platform
5 import sys
5 import sys
6
6
7 import setuptools
7 import setuptools
8 # monkey patch setuptools to use distutils owner/group functionality
8 # monkey patch setuptools to use distutils owner/group functionality
9 from setuptools.command import sdist
9 from setuptools.command import sdist
10
10
11
11
12 if sys.version_info < (3, 6):
12 if sys.version_info < (3, 6):
13 raise Exception('Kallithea requires Python 3.6 or later')
13 raise Exception('Kallithea requires Python 3.6 or later')
14
14
15
15
16 here = os.path.abspath(os.path.dirname(__file__))
16 here = os.path.abspath(os.path.dirname(__file__))
17
17
18
18
19 def _get_meta_var(name, data, callback_handler=None):
19 def _get_meta_var(name, data, callback_handler=None):
20 import re
20 import re
21 matches = re.compile(r'(?:%s)\s*=\s*(.*)' % name).search(data)
21 matches = re.compile(r'(?:%s)\s*=\s*(.*)' % name).search(data)
22 if matches:
22 if matches:
23 if not callable(callback_handler):
23 if not callable(callback_handler):
24 callback_handler = lambda v: v
24 callback_handler = lambda v: v
25
25
26 return callback_handler(eval(matches.groups()[0]))
26 return callback_handler(eval(matches.groups()[0]))
27
27
28 _meta = open(os.path.join(here, 'kallithea', '__init__.py'), 'r')
28 _meta = open(os.path.join(here, 'kallithea', '__init__.py'), 'r')
29 _metadata = _meta.read()
29 _metadata = _meta.read()
30 _meta.close()
30 _meta.close()
31
31
32 callback = lambda V: ('.'.join(map(str, V[:3])) + '.'.join(V[3:]))
32 callback = lambda V: ('.'.join(map(str, V[:3])) + '.'.join(V[3:]))
33 __version__ = _get_meta_var('VERSION', _metadata, callback)
33 __version__ = _get_meta_var('VERSION', _metadata, callback)
34 __license__ = _get_meta_var('__license__', _metadata)
34 __license__ = _get_meta_var('__license__', _metadata)
35 __author__ = _get_meta_var('__author__', _metadata)
35 __author__ = _get_meta_var('__author__', _metadata)
36 __url__ = _get_meta_var('__url__', _metadata)
36 __url__ = _get_meta_var('__url__', _metadata)
37 # defines current platform
37 # defines current platform
38 __platform__ = platform.system()
38 __platform__ = platform.system()
39
39
40 is_windows = __platform__ in ['Windows']
40 is_windows = __platform__ in ['Windows']
41
41
42 requirements = [
42 requirements = [
43 "alembic >= 1.0.10, < 1.5",
43 "alembic >= 1.0.10, < 1.5",
44 "gearbox >= 0.1.0, < 1",
44 "gearbox >= 0.1.0, < 1",
45 "waitress >= 0.8.8, < 1.5",
45 "waitress >= 0.8.8, < 1.5",
46 "WebOb >= 1.8, < 1.9",
46 "WebOb >= 1.8, < 1.9",
47 "backlash >= 0.1.2, < 1",
47 "backlash >= 0.1.2, < 1",
48 "TurboGears2 >= 2.4, < 2.5",
48 "TurboGears2 >= 2.4, < 2.5",
49 "tgext.routes >= 0.2.0, < 1",
49 "tgext.routes >= 0.2.0, < 1",
50 "Beaker >= 1.10.1, < 2",
50 "Beaker >= 1.10.1, < 2",
51 "WebHelpers2 >= 2.0, < 2.1",
51 "WebHelpers2 >= 2.0, < 2.1",
52 "FormEncode >= 1.3.1, < 1.4",
52 "FormEncode >= 1.3.1, < 1.4",
53 "SQLAlchemy >= 1.2.9, < 1.4",
53 "SQLAlchemy >= 1.2.9, < 1.4",
54 "Mako >= 0.9.1, < 1.2",
54 "Mako >= 0.9.1, < 1.2",
55 "Pygments >= 2.2.0, < 2.6",
55 "Pygments >= 2.2.0, < 2.6",
56 "Whoosh >= 2.7.1, < 2.8",
56 "Whoosh >= 2.7.1, < 2.8",
57 "celery >= 3.1, < 4.0", # TODO: celery 4 doesn't work
57 "celery >= 3.1, < 4.0", # TODO: celery 4 doesn't work
58 "Babel >= 1.3, < 2.9",
58 "Babel >= 1.3, < 2.9",
59 "python-dateutil >= 2.1.0, < 2.9",
59 "python-dateutil >= 2.1.0, < 2.9",
60 "Markdown >= 2.2.1, < 3.2",
60 "Markdown >= 2.2.1, < 3.2",
61 "docutils >= 0.11, < 0.17",
61 "docutils >= 0.11, < 0.17",
62 "URLObject >= 2.3.4, < 2.5",
62 "URLObject >= 2.3.4, < 2.5",
63 "Routes >= 2.0, < 2.5",
63 "Routes >= 2.0, < 2.5",
64 "dulwich >= 0.19.0, < 0.20",
64 "dulwich >= 0.19.0, < 0.20",
65 "mercurial >= 5.2, < 5.4",
65 "mercurial >= 5.2, < 5.4",
66 "decorator >= 4.2.1, < 4.5",
66 "decorator >= 4.2.1, < 4.5",
67 "Paste >= 2.0.3, < 3.4",
67 "Paste >= 2.0.3, < 3.4",
68 "bleach >= 3.0, < 3.2",
68 "bleach >= 3.0, < 3.2",
69 "Click >= 7.0, < 8",
69 "Click >= 7.0, < 8",
70 "ipaddr >= 2.2.0, < 2.3",
70 "ipaddr >= 2.2.0, < 2.3",
71 "paginate >= 0.5, < 0.6",
71 "paginate >= 0.5, < 0.6",
72 "paginate_sqlalchemy >= 0.3.0, < 0.4",
72 "paginate_sqlalchemy >= 0.3.0, < 0.4",
73 ]
73 ]
74
74
75 if not is_windows:
75 if not is_windows:
76 requirements.append("bcrypt >= 3.1.0, < 3.2")
76 requirements.append("bcrypt >= 3.1.0, < 3.2")
77
77
78 dependency_links = [
78 dependency_links = [
79 ]
79 ]
80
80
81 classifiers = [
81 classifiers = [
82 'Development Status :: 4 - Beta',
82 'Development Status :: 4 - Beta',
83 'Environment :: Web Environment',
83 'Environment :: Web Environment',
84 'Framework :: Pylons',
84 'Framework :: Pylons',
85 'Intended Audience :: Developers',
85 'Intended Audience :: Developers',
86 'License :: OSI Approved :: GNU General Public License (GPL)',
86 'License :: OSI Approved :: GNU General Public License (GPL)',
87 'Operating System :: OS Independent',
87 'Operating System :: OS Independent',
88 'Programming Language :: Python :: 3.6',
88 'Programming Language :: Python :: 3.6',
89 'Programming Language :: Python :: 3.7',
89 'Programming Language :: Python :: 3.7',
90 'Programming Language :: Python :: 3.8',
90 'Programming Language :: Python :: 3.8',
91 'Topic :: Software Development :: Version Control',
91 'Topic :: Software Development :: Version Control',
92 ]
92 ]
93
93
94
94
95 # additional files from project that goes somewhere in the filesystem
95 # additional files from project that goes somewhere in the filesystem
96 # relative to sys.prefix
96 # relative to sys.prefix
97 data_files = []
97 data_files = []
98
98
99 description = ('Kallithea is a fast and powerful management tool '
99 description = ('Kallithea is a fast and powerful management tool '
100 'for Mercurial and Git with a built in push/pull server, '
100 'for Mercurial and Git with a built in push/pull server, '
101 'full text search and code-review.')
101 'full text search and code-review.')
102
102
103 keywords = ' '.join([
103 keywords = ' '.join([
104 'kallithea', 'mercurial', 'git', 'code review',
104 'kallithea', 'mercurial', 'git', 'code review',
105 'repo groups', 'ldap', 'repository management', 'hgweb replacement',
105 'repo groups', 'ldap', 'repository management', 'hgweb replacement',
106 'hgwebdir', 'gitweb replacement', 'serving hgweb',
106 'hgwebdir', 'gitweb replacement', 'serving hgweb',
107 ])
107 ])
108
108
109 # long description
109 # long description
110 README_FILE = 'README.rst'
110 README_FILE = 'README.rst'
111 try:
111 try:
112 long_description = open(README_FILE).read()
112 long_description = open(README_FILE).read()
113 except IOError as err:
113 except IOError as err:
114 sys.stderr.write(
114 sys.stderr.write(
115 "[WARNING] Cannot find file specified as long_description (%s)\n"
115 "[WARNING] Cannot find file specified as long_description (%s): %s\n"
116 % README_FILE
116 % (README_FILE, err)
117 )
117 )
118 long_description = description
118 long_description = description
119
119
120
120
121 sdist_org = sdist.sdist
121 sdist_org = sdist.sdist
122 class sdist_new(sdist_org):
122 class sdist_new(sdist_org):
123 def initialize_options(self):
123 def initialize_options(self):
124 sdist_org.initialize_options(self)
124 sdist_org.initialize_options(self)
125 self.owner = self.group = 'root'
125 self.owner = self.group = 'root'
126 sdist.sdist = sdist_new
126 sdist.sdist = sdist_new
127
127
128 packages = setuptools.find_packages(exclude=['ez_setup'])
128 packages = setuptools.find_packages(exclude=['ez_setup'])
129
129
130 setuptools.setup(
130 setuptools.setup(
131 name='Kallithea',
131 name='Kallithea',
132 version=__version__,
132 version=__version__,
133 description=description,
133 description=description,
134 long_description=long_description,
134 long_description=long_description,
135 keywords=keywords,
135 keywords=keywords,
136 license=__license__,
136 license=__license__,
137 author=__author__,
137 author=__author__,
138 author_email='kallithea@sfconservancy.org',
138 author_email='kallithea@sfconservancy.org',
139 dependency_links=dependency_links,
139 dependency_links=dependency_links,
140 url=__url__,
140 url=__url__,
141 install_requires=requirements,
141 install_requires=requirements,
142 classifiers=classifiers,
142 classifiers=classifiers,
143 data_files=data_files,
143 data_files=data_files,
144 packages=packages,
144 packages=packages,
145 include_package_data=True,
145 include_package_data=True,
146 message_extractors={'kallithea': [
146 message_extractors={'kallithea': [
147 ('**.py', 'python', None),
147 ('**.py', 'python', None),
148 ('templates/**.mako', 'mako', {'input_encoding': 'utf-8'}),
148 ('templates/**.mako', 'mako', {'input_encoding': 'utf-8'}),
149 ('templates/**.html', 'mako', {'input_encoding': 'utf-8'}),
149 ('templates/**.html', 'mako', {'input_encoding': 'utf-8'}),
150 ('public/**', 'ignore', None)]},
150 ('public/**', 'ignore', None)]},
151 zip_safe=False,
151 zip_safe=False,
152 entry_points="""
152 entry_points="""
153 [console_scripts]
153 [console_scripts]
154 kallithea-api = kallithea.bin.kallithea_api:main
154 kallithea-api = kallithea.bin.kallithea_api:main
155 kallithea-gist = kallithea.bin.kallithea_gist:main
155 kallithea-gist = kallithea.bin.kallithea_gist:main
156 kallithea-config = kallithea.bin.kallithea_config:main
156 kallithea-config = kallithea.bin.kallithea_config:main
157 kallithea-cli = kallithea.bin.kallithea_cli:cli
157 kallithea-cli = kallithea.bin.kallithea_cli:cli
158
158
159 [paste.app_factory]
159 [paste.app_factory]
160 main = kallithea.config.middleware:make_app
160 main = kallithea.config.middleware:make_app
161 """,
161 """,
162 )
162 )
General Comments 0
You need to be logged in to leave comments. Login now