##// END OF EJS Templates
emails: added note types into emails. Fixes #5221
marcink -
r1453:4b56dcb1 default
parent child Browse files
Show More
@@ -1,634 +1,635 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from datetime import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pyramid.threadlocal import get_current_registry
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 36 from rhodecode.lib import helpers as h, diffs
37 37 from rhodecode.lib.channelstream import channelstream_request
38 38 from rhodecode.lib.utils import action_logger
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest, AttributeDict)
43 43 from rhodecode.model.notification import NotificationModel
44 44 from rhodecode.model.meta import Session
45 45 from rhodecode.model.settings import VcsSettingsModel
46 46 from rhodecode.model.notification import EmailNotificationModel
47 47 from rhodecode.model.validation_schema.schemas import comment_schema
48 48
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52
53 53 class CommentsModel(BaseModel):
54 54
55 55 cls = ChangesetComment
56 56
57 57 DIFF_CONTEXT_BEFORE = 3
58 58 DIFF_CONTEXT_AFTER = 3
59 59
60 60 def __get_commit_comment(self, changeset_comment):
61 61 return self._get_instance(ChangesetComment, changeset_comment)
62 62
63 63 def __get_pull_request(self, pull_request):
64 64 return self._get_instance(PullRequest, pull_request)
65 65
66 66 def _extract_mentions(self, s):
67 67 user_objects = []
68 68 for username in extract_mentioned_users(s):
69 69 user_obj = User.get_by_username(username, case_insensitive=True)
70 70 if user_obj:
71 71 user_objects.append(user_obj)
72 72 return user_objects
73 73
74 74 def _get_renderer(self, global_renderer='rst'):
75 75 try:
76 76 # try reading from visual context
77 77 from pylons import tmpl_context
78 78 global_renderer = tmpl_context.visual.default_renderer
79 79 except AttributeError:
80 80 log.debug("Renderer not set, falling back "
81 81 "to default renderer '%s'", global_renderer)
82 82 except Exception:
83 83 log.error(traceback.format_exc())
84 84 return global_renderer
85 85
86 86 def aggregate_comments(self, comments, versions, show_version, inline=False):
87 87 # group by versions, and count until, and display objects
88 88
89 89 comment_groups = collections.defaultdict(list)
90 90 [comment_groups[
91 91 _co.pull_request_version_id].append(_co) for _co in comments]
92 92
93 93 def yield_comments(pos):
94 94 for co in comment_groups[pos]:
95 95 yield co
96 96
97 97 comment_versions = collections.defaultdict(
98 98 lambda: collections.defaultdict(list))
99 99 prev_prvid = -1
100 100 # fake last entry with None, to aggregate on "latest" version which
101 101 # doesn't have an pull_request_version_id
102 102 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
103 103 prvid = ver.pull_request_version_id
104 104 if prev_prvid == -1:
105 105 prev_prvid = prvid
106 106
107 107 for co in yield_comments(prvid):
108 108 comment_versions[prvid]['at'].append(co)
109 109
110 110 # save until
111 111 current = comment_versions[prvid]['at']
112 112 prev_until = comment_versions[prev_prvid]['until']
113 113 cur_until = prev_until + current
114 114 comment_versions[prvid]['until'].extend(cur_until)
115 115
116 116 # save outdated
117 117 if inline:
118 118 outdated = [x for x in cur_until
119 119 if x.outdated_at_version(show_version)]
120 120 else:
121 121 outdated = [x for x in cur_until
122 122 if x.older_than_version(show_version)]
123 123 display = [x for x in cur_until if x not in outdated]
124 124
125 125 comment_versions[prvid]['outdated'] = outdated
126 126 comment_versions[prvid]['display'] = display
127 127
128 128 prev_prvid = prvid
129 129
130 130 return comment_versions
131 131
132 132 def get_unresolved_todos(self, pull_request, show_outdated=True):
133 133
134 134 todos = Session().query(ChangesetComment) \
135 135 .filter(ChangesetComment.pull_request == pull_request) \
136 136 .filter(ChangesetComment.resolved_by == None) \
137 137 .filter(ChangesetComment.comment_type
138 138 == ChangesetComment.COMMENT_TYPE_TODO)
139 139
140 140 if not show_outdated:
141 141 todos = todos.filter(
142 142 coalesce(ChangesetComment.display_state, '') !=
143 143 ChangesetComment.COMMENT_OUTDATED)
144 144
145 145 todos = todos.all()
146 146
147 147 return todos
148 148
149 149 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
150 150
151 151 todos = Session().query(ChangesetComment) \
152 152 .filter(ChangesetComment.revision == commit_id) \
153 153 .filter(ChangesetComment.resolved_by == None) \
154 154 .filter(ChangesetComment.comment_type
155 155 == ChangesetComment.COMMENT_TYPE_TODO)
156 156
157 157 if not show_outdated:
158 158 todos = todos.filter(
159 159 coalesce(ChangesetComment.display_state, '') !=
160 160 ChangesetComment.COMMENT_OUTDATED)
161 161
162 162 todos = todos.all()
163 163
164 164 return todos
165 165
166 166 def create(self, text, repo, user, commit_id=None, pull_request=None,
167 167 f_path=None, line_no=None, status_change=None,
168 168 status_change_type=None, comment_type=None,
169 169 resolves_comment_id=None, closing_pr=False, send_email=True,
170 170 renderer=None):
171 171 """
172 172 Creates new comment for commit or pull request.
173 173 IF status_change is not none this comment is associated with a
174 174 status change of commit or commit associated with pull request
175 175
176 176 :param text:
177 177 :param repo:
178 178 :param user:
179 179 :param commit_id:
180 180 :param pull_request:
181 181 :param f_path:
182 182 :param line_no:
183 183 :param status_change: Label for status change
184 184 :param comment_type: Type of comment
185 185 :param status_change_type: type of status change
186 186 :param closing_pr:
187 187 :param send_email:
188 188 :param renderer: pick renderer for this comment
189 189 """
190 190 if not text:
191 191 log.warning('Missing text for comment, skipping...')
192 192 return
193 193
194 194 if not renderer:
195 195 renderer = self._get_renderer()
196 196
197 197 repo = self._get_repo(repo)
198 198 user = self._get_user(user)
199 199
200 200 schema = comment_schema.CommentSchema()
201 201 validated_kwargs = schema.deserialize(dict(
202 202 comment_body=text,
203 203 comment_type=comment_type,
204 204 comment_file=f_path,
205 205 comment_line=line_no,
206 206 renderer_type=renderer,
207 207 status_change=status_change_type,
208 208 resolves_comment_id=resolves_comment_id,
209 209 repo=repo.repo_id,
210 210 user=user.user_id,
211 211 ))
212 212
213 213 comment = ChangesetComment()
214 214 comment.renderer = validated_kwargs['renderer_type']
215 215 comment.text = validated_kwargs['comment_body']
216 216 comment.f_path = validated_kwargs['comment_file']
217 217 comment.line_no = validated_kwargs['comment_line']
218 218 comment.comment_type = validated_kwargs['comment_type']
219 219
220 220 comment.repo = repo
221 221 comment.author = user
222 222 comment.resolved_comment = self.__get_commit_comment(
223 223 validated_kwargs['resolves_comment_id'])
224 224
225 225 pull_request_id = pull_request
226 226
227 227 commit_obj = None
228 228 pull_request_obj = None
229 229
230 230 if commit_id:
231 231 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
232 232 # do a lookup, so we don't pass something bad here
233 233 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
234 234 comment.revision = commit_obj.raw_id
235 235
236 236 elif pull_request_id:
237 237 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
238 238 pull_request_obj = self.__get_pull_request(pull_request_id)
239 239 comment.pull_request = pull_request_obj
240 240 else:
241 241 raise Exception('Please specify commit or pull_request_id')
242 242
243 243 Session().add(comment)
244 244 Session().flush()
245 245 kwargs = {
246 246 'user': user,
247 247 'renderer_type': renderer,
248 248 'repo_name': repo.repo_name,
249 249 'status_change': status_change,
250 250 'status_change_type': status_change_type,
251 251 'comment_body': text,
252 252 'comment_file': f_path,
253 253 'comment_line': line_no,
254 'comment_type': comment_type or 'note'
254 255 }
255 256
256 257 if commit_obj:
257 258 recipients = ChangesetComment.get_users(
258 259 revision=commit_obj.raw_id)
259 260 # add commit author if it's in RhodeCode system
260 261 cs_author = User.get_from_cs_author(commit_obj.author)
261 262 if not cs_author:
262 263 # use repo owner if we cannot extract the author correctly
263 264 cs_author = repo.user
264 265 recipients += [cs_author]
265 266
266 267 commit_comment_url = self.get_url(comment)
267 268
268 269 target_repo_url = h.link_to(
269 270 repo.repo_name,
270 271 h.url('summary_home',
271 272 repo_name=repo.repo_name, qualified=True))
272 273
273 274 # commit specifics
274 275 kwargs.update({
275 276 'commit': commit_obj,
276 277 'commit_message': commit_obj.message,
277 278 'commit_target_repo': target_repo_url,
278 279 'commit_comment_url': commit_comment_url,
279 280 })
280 281
281 282 elif pull_request_obj:
282 283 # get the current participants of this pull request
283 284 recipients = ChangesetComment.get_users(
284 285 pull_request_id=pull_request_obj.pull_request_id)
285 286 # add pull request author
286 287 recipients += [pull_request_obj.author]
287 288
288 289 # add the reviewers to notification
289 290 recipients += [x.user for x in pull_request_obj.reviewers]
290 291
291 292 pr_target_repo = pull_request_obj.target_repo
292 293 pr_source_repo = pull_request_obj.source_repo
293 294
294 295 pr_comment_url = h.url(
295 296 'pullrequest_show',
296 297 repo_name=pr_target_repo.repo_name,
297 298 pull_request_id=pull_request_obj.pull_request_id,
298 299 anchor='comment-%s' % comment.comment_id,
299 300 qualified=True,)
300 301
301 302 # set some variables for email notification
302 303 pr_target_repo_url = h.url(
303 304 'summary_home', repo_name=pr_target_repo.repo_name,
304 305 qualified=True)
305 306
306 307 pr_source_repo_url = h.url(
307 308 'summary_home', repo_name=pr_source_repo.repo_name,
308 309 qualified=True)
309 310
310 311 # pull request specifics
311 312 kwargs.update({
312 313 'pull_request': pull_request_obj,
313 314 'pr_id': pull_request_obj.pull_request_id,
314 315 'pr_target_repo': pr_target_repo,
315 316 'pr_target_repo_url': pr_target_repo_url,
316 317 'pr_source_repo': pr_source_repo,
317 318 'pr_source_repo_url': pr_source_repo_url,
318 319 'pr_comment_url': pr_comment_url,
319 320 'pr_closing': closing_pr,
320 321 })
321 322 if send_email:
322 323 # pre-generate the subject for notification itself
323 324 (subject,
324 325 _h, _e, # we don't care about those
325 326 body_plaintext) = EmailNotificationModel().render_email(
326 327 notification_type, **kwargs)
327 328
328 329 mention_recipients = set(
329 330 self._extract_mentions(text)).difference(recipients)
330 331
331 332 # create notification objects, and emails
332 333 NotificationModel().create(
333 334 created_by=user,
334 335 notification_subject=subject,
335 336 notification_body=body_plaintext,
336 337 notification_type=notification_type,
337 338 recipients=recipients,
338 339 mention_recipients=mention_recipients,
339 340 email_kwargs=kwargs,
340 341 )
341 342
342 343 action = (
343 344 'user_commented_pull_request:{}'.format(
344 345 comment.pull_request.pull_request_id)
345 346 if comment.pull_request
346 347 else 'user_commented_revision:{}'.format(comment.revision)
347 348 )
348 349 action_logger(user, action, comment.repo)
349 350
350 351 registry = get_current_registry()
351 352 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
352 353 channelstream_config = rhodecode_plugins.get('channelstream', {})
353 354 msg_url = ''
354 355 if commit_obj:
355 356 msg_url = commit_comment_url
356 357 repo_name = repo.repo_name
357 358 elif pull_request_obj:
358 359 msg_url = pr_comment_url
359 360 repo_name = pr_target_repo.repo_name
360 361
361 362 if channelstream_config.get('enabled'):
362 363 message = '<strong>{}</strong> {} - ' \
363 364 '<a onclick="window.location=\'{}\';' \
364 365 'window.location.reload()">' \
365 366 '<strong>{}</strong></a>'
366 367 message = message.format(
367 368 user.username, _('made a comment'), msg_url,
368 369 _('Show it now'))
369 370 channel = '/repo${}$/pr/{}'.format(
370 371 repo_name,
371 372 pull_request_id
372 373 )
373 374 payload = {
374 375 'type': 'message',
375 376 'timestamp': datetime.utcnow(),
376 377 'user': 'system',
377 378 'exclude_users': [user.username],
378 379 'channel': channel,
379 380 'message': {
380 381 'message': message,
381 382 'level': 'info',
382 383 'topic': '/notifications'
383 384 }
384 385 }
385 386 channelstream_request(channelstream_config, [payload],
386 387 '/message', raise_exc=False)
387 388
388 389 return comment
389 390
390 391 def delete(self, comment):
391 392 """
392 393 Deletes given comment
393 394
394 395 :param comment_id:
395 396 """
396 397 comment = self.__get_commit_comment(comment)
397 398 Session().delete(comment)
398 399
399 400 return comment
400 401
401 402 def get_all_comments(self, repo_id, revision=None, pull_request=None):
402 403 q = ChangesetComment.query()\
403 404 .filter(ChangesetComment.repo_id == repo_id)
404 405 if revision:
405 406 q = q.filter(ChangesetComment.revision == revision)
406 407 elif pull_request:
407 408 pull_request = self.__get_pull_request(pull_request)
408 409 q = q.filter(ChangesetComment.pull_request == pull_request)
409 410 else:
410 411 raise Exception('Please specify commit or pull_request')
411 412 q = q.order_by(ChangesetComment.created_on)
412 413 return q.all()
413 414
414 415 def get_url(self, comment):
415 416 comment = self.__get_commit_comment(comment)
416 417 if comment.pull_request:
417 418 return h.url(
418 419 'pullrequest_show',
419 420 repo_name=comment.pull_request.target_repo.repo_name,
420 421 pull_request_id=comment.pull_request.pull_request_id,
421 422 anchor='comment-%s' % comment.comment_id,
422 423 qualified=True,)
423 424 else:
424 425 return h.url(
425 426 'changeset_home',
426 427 repo_name=comment.repo.repo_name,
427 428 revision=comment.revision,
428 429 anchor='comment-%s' % comment.comment_id,
429 430 qualified=True,)
430 431
431 432 def get_comments(self, repo_id, revision=None, pull_request=None):
432 433 """
433 434 Gets main comments based on revision or pull_request_id
434 435
435 436 :param repo_id:
436 437 :param revision:
437 438 :param pull_request:
438 439 """
439 440
440 441 q = ChangesetComment.query()\
441 442 .filter(ChangesetComment.repo_id == repo_id)\
442 443 .filter(ChangesetComment.line_no == None)\
443 444 .filter(ChangesetComment.f_path == None)
444 445 if revision:
445 446 q = q.filter(ChangesetComment.revision == revision)
446 447 elif pull_request:
447 448 pull_request = self.__get_pull_request(pull_request)
448 449 q = q.filter(ChangesetComment.pull_request == pull_request)
449 450 else:
450 451 raise Exception('Please specify commit or pull_request')
451 452 q = q.order_by(ChangesetComment.created_on)
452 453 return q.all()
453 454
454 455 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
455 456 q = self._get_inline_comments_query(repo_id, revision, pull_request)
456 457 return self._group_comments_by_path_and_line_number(q)
457 458
458 459 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
459 460 version=None):
460 461 inline_cnt = 0
461 462 for fname, per_line_comments in inline_comments.iteritems():
462 463 for lno, comments in per_line_comments.iteritems():
463 464 for comm in comments:
464 465 if not comm.outdated_at_version(version) and skip_outdated:
465 466 inline_cnt += 1
466 467
467 468 return inline_cnt
468 469
469 470 def get_outdated_comments(self, repo_id, pull_request):
470 471 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
471 472 # of a pull request.
472 473 q = self._all_inline_comments_of_pull_request(pull_request)
473 474 q = q.filter(
474 475 ChangesetComment.display_state ==
475 476 ChangesetComment.COMMENT_OUTDATED
476 477 ).order_by(ChangesetComment.comment_id.asc())
477 478
478 479 return self._group_comments_by_path_and_line_number(q)
479 480
480 481 def _get_inline_comments_query(self, repo_id, revision, pull_request):
481 482 # TODO: johbo: Split this into two methods: One for PR and one for
482 483 # commit.
483 484 if revision:
484 485 q = Session().query(ChangesetComment).filter(
485 486 ChangesetComment.repo_id == repo_id,
486 487 ChangesetComment.line_no != null(),
487 488 ChangesetComment.f_path != null(),
488 489 ChangesetComment.revision == revision)
489 490
490 491 elif pull_request:
491 492 pull_request = self.__get_pull_request(pull_request)
492 493 if not CommentsModel.use_outdated_comments(pull_request):
493 494 q = self._visible_inline_comments_of_pull_request(pull_request)
494 495 else:
495 496 q = self._all_inline_comments_of_pull_request(pull_request)
496 497
497 498 else:
498 499 raise Exception('Please specify commit or pull_request_id')
499 500 q = q.order_by(ChangesetComment.comment_id.asc())
500 501 return q
501 502
502 503 def _group_comments_by_path_and_line_number(self, q):
503 504 comments = q.all()
504 505 paths = collections.defaultdict(lambda: collections.defaultdict(list))
505 506 for co in comments:
506 507 paths[co.f_path][co.line_no].append(co)
507 508 return paths
508 509
509 510 @classmethod
510 511 def needed_extra_diff_context(cls):
511 512 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
512 513
513 514 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
514 515 if not CommentsModel.use_outdated_comments(pull_request):
515 516 return
516 517
517 518 comments = self._visible_inline_comments_of_pull_request(pull_request)
518 519 comments_to_outdate = comments.all()
519 520
520 521 for comment in comments_to_outdate:
521 522 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
522 523
523 524 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
524 525 diff_line = _parse_comment_line_number(comment.line_no)
525 526
526 527 try:
527 528 old_context = old_diff_proc.get_context_of_line(
528 529 path=comment.f_path, diff_line=diff_line)
529 530 new_context = new_diff_proc.get_context_of_line(
530 531 path=comment.f_path, diff_line=diff_line)
531 532 except (diffs.LineNotInDiffException,
532 533 diffs.FileNotInDiffException):
533 534 comment.display_state = ChangesetComment.COMMENT_OUTDATED
534 535 return
535 536
536 537 if old_context == new_context:
537 538 return
538 539
539 540 if self._should_relocate_diff_line(diff_line):
540 541 new_diff_lines = new_diff_proc.find_context(
541 542 path=comment.f_path, context=old_context,
542 543 offset=self.DIFF_CONTEXT_BEFORE)
543 544 if not new_diff_lines:
544 545 comment.display_state = ChangesetComment.COMMENT_OUTDATED
545 546 else:
546 547 new_diff_line = self._choose_closest_diff_line(
547 548 diff_line, new_diff_lines)
548 549 comment.line_no = _diff_to_comment_line_number(new_diff_line)
549 550 else:
550 551 comment.display_state = ChangesetComment.COMMENT_OUTDATED
551 552
552 553 def _should_relocate_diff_line(self, diff_line):
553 554 """
554 555 Checks if relocation shall be tried for the given `diff_line`.
555 556
556 557 If a comment points into the first lines, then we can have a situation
557 558 that after an update another line has been added on top. In this case
558 559 we would find the context still and move the comment around. This
559 560 would be wrong.
560 561 """
561 562 should_relocate = (
562 563 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
563 564 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
564 565 return should_relocate
565 566
566 567 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
567 568 candidate = new_diff_lines[0]
568 569 best_delta = _diff_line_delta(diff_line, candidate)
569 570 for new_diff_line in new_diff_lines[1:]:
570 571 delta = _diff_line_delta(diff_line, new_diff_line)
571 572 if delta < best_delta:
572 573 candidate = new_diff_line
573 574 best_delta = delta
574 575 return candidate
575 576
576 577 def _visible_inline_comments_of_pull_request(self, pull_request):
577 578 comments = self._all_inline_comments_of_pull_request(pull_request)
578 579 comments = comments.filter(
579 580 coalesce(ChangesetComment.display_state, '') !=
580 581 ChangesetComment.COMMENT_OUTDATED)
581 582 return comments
582 583
583 584 def _all_inline_comments_of_pull_request(self, pull_request):
584 585 comments = Session().query(ChangesetComment)\
585 586 .filter(ChangesetComment.line_no != None)\
586 587 .filter(ChangesetComment.f_path != None)\
587 588 .filter(ChangesetComment.pull_request == pull_request)
588 589 return comments
589 590
590 591 def _all_general_comments_of_pull_request(self, pull_request):
591 592 comments = Session().query(ChangesetComment)\
592 593 .filter(ChangesetComment.line_no == None)\
593 594 .filter(ChangesetComment.f_path == None)\
594 595 .filter(ChangesetComment.pull_request == pull_request)
595 596 return comments
596 597
597 598 @staticmethod
598 599 def use_outdated_comments(pull_request):
599 600 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
600 601 settings = settings_model.get_general_settings()
601 602 return settings.get('rhodecode_use_outdated_comments', False)
602 603
603 604
604 605 def _parse_comment_line_number(line_no):
605 606 """
606 607 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
607 608 """
608 609 old_line = None
609 610 new_line = None
610 611 if line_no.startswith('o'):
611 612 old_line = int(line_no[1:])
612 613 elif line_no.startswith('n'):
613 614 new_line = int(line_no[1:])
614 615 else:
615 616 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
616 617 return diffs.DiffLineNumber(old_line, new_line)
617 618
618 619
619 620 def _diff_to_comment_line_number(diff_line):
620 621 if diff_line.new is not None:
621 622 return u'n{}'.format(diff_line.new)
622 623 elif diff_line.old is not None:
623 624 return u'o{}'.format(diff_line.old)
624 625 return u''
625 626
626 627
627 628 def _diff_line_delta(a, b):
628 629 if None not in (a.new, b.new):
629 630 return abs(a.new - b.new)
630 631 elif None not in (a.old, b.old):
631 632 return abs(a.old - b.old)
632 633 else:
633 634 raise ValueError(
634 635 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,88 +1,105 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3 <%namespace name="base" file="base.mako"/>
4 4
5 ## EMAIL SUBJECT
5 6 <%def name="subject()" filter="n,trim">
6 7 <%
7 8 data = {
8 9 'user': h.person(user),
9 10 'repo_name': repo_name,
10 11 'commit_id': h.show_id(commit),
11 12 'status': status_change,
12 13 'comment_file': comment_file,
13 14 'comment_line': comment_line,
15 'comment_type': comment_type,
14 16 }
15 17 %>
16 18 ${_('[mention]') if mention else ''} \
17 19
18 20 % if comment_file:
19 ${_('%(user)s commented on commit `%(commit_id)s` (file: `%(comment_file)s`)') % data} ${_('in the %(repo_name)s repository') % data |n}
21 ${_('%(user)s left %(comment_type)s on commit `%(commit_id)s` (file: `%(comment_file)s`)') % data} ${_('in the %(repo_name)s repository') % data |n}
20 22 % else:
21 23 % if status_change:
22 ${_('%(user)s commented on commit `%(commit_id)s` (status: %(status)s)') % data |n} ${_('in the %(repo_name)s repository') % data |n}
24 ${_('%(user)s left %(comment_type)s on commit `%(commit_id)s` (status: %(status)s)') % data |n} ${_('in the %(repo_name)s repository') % data |n}
23 25 % else:
24 ${_('%(user)s commented on commit `%(commit_id)s`') % data |n} ${_('in the %(repo_name)s repository') % data |n}
26 ${_('%(user)s left %(comment_type)s on commit `%(commit_id)s`') % data |n} ${_('in the %(repo_name)s repository') % data |n}
25 27 % endif
26 28 % endif
27 29
28 30 </%def>
29 31
32 ## PLAINTEXT VERSION OF BODY
30 33 <%def name="body_plaintext()" filter="n,trim">
31 34 <%
32 35 data = {
33 36 'user': h.person(user),
34 37 'repo_name': repo_name,
35 38 'commit_id': h.show_id(commit),
36 39 'status': status_change,
37 40 'comment_file': comment_file,
38 41 'comment_line': comment_line,
42 'comment_type': comment_type,
39 43 }
40 44 %>
41 45 ${self.subject()}
42 46
43 47 * ${_('Comment link')}: ${commit_comment_url}
44 48
45 49 * ${_('Commit')}: ${h.show_id(commit)}
46 50
47 51 %if comment_file:
48 * ${_('File: %(comment_file)s on line %(comment_line)s') % {'comment_file': comment_file, 'comment_line': comment_line}}
52 * ${_('File: %(comment_file)s on line %(comment_line)s') % data}
49 53 %endif
50 54
51 55 ---
52 56
53 57 %if status_change:
54 58 ${_('Commit status was changed to')}: *${status_change}*
55 59 %endif
56 60
57 61 ${comment_body|n}
58 62
59 63 ${self.plaintext_footer()}
60 64 </%def>
61 65
62 66
63 67 <%
64 68 data = {
65 69 'user': h.person(user),
66 'comment_file': comment_file,
67 'comment_line': comment_line,
68 70 'repo': commit_target_repo,
69 71 'repo_name': repo_name,
70 72 'commit_id': h.show_id(commit),
73 'comment_file': comment_file,
74 'comment_line': comment_line,
75 'comment_type': comment_type,
71 76 }
72 77 %>
73 78 <table style="text-align:left;vertical-align:middle;">
74 79 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;">
80
75 81 % if comment_file:
76 82 <h4><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s commented on commit `%(commit_id)s` (file:`%(comment_file)s`)') % data}</a> ${_('in the %(repo)s repository') % data |n}</h4>
77 83 % else:
78 84 <h4><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s commented on commit `%(commit_id)s`') % data |n}</a> ${_('in the %(repo)s repository') % data |n}</h4>
79 85 % endif
80 86 </td></tr>
87
81 88 <tr><td style="padding-right:20px;padding-top:15px;">${_('Commit')}</td><td style="padding-top:15px;"><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${h.show_id(commit)}</a></td></tr>
82 89 <tr><td style="padding-right:20px;">${_('Description')}</td><td style="white-space:pre-wrap">${h.urlify_commit_message(commit.message, repo_name)}</td></tr>
83 90
84 91 % if status_change:
85 <tr><td style="padding-right:20px;">${_('Status')}</td><td>${_('The commit status was changed to')}: ${base.status_text(status_change, tag_type=status_change_type)}</td></tr>
92 <tr><td style="padding-right:20px;">${_('Status')}</td>
93 <td>${_('The commit status was changed to')}: ${base.status_text(status_change, tag_type=status_change_type)}</td>
94 </tr>
86 95 % endif
87 <tr><td style="padding-right:20px;">${(_('Comment on line: %(comment_line)s') if comment_file else _('Comment')) % data}</td><td style="line-height:1.2em;white-space:pre-wrap">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td></tr>
96 <tr>
97 <td style="padding-right:20px;">
98 % if comment_type == 'todo':
99 ${(_('TODO comment on line: %(comment_line)s') if comment_file else _('TODO comment')) % data}
100 % else:
101 ${(_('Note comment on line: %(comment_line)s') if comment_file else _('Note comment')) % data}
102 % endif
103 </td>
104 <td style="line-height:1.2em;white-space:pre-wrap">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td></tr>
88 105 </table>
@@ -1,98 +1,114 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3 <%namespace name="base" file="base.mako"/>
4 4
5
5 ## EMAIL SUBJECT
6 6 <%def name="subject()" filter="n,trim">
7 7 <%
8 8 data = {
9 9 'user': h.person(user),
10 10 'pr_title': pull_request.title,
11 11 'pr_id': pull_request.pull_request_id,
12 12 'status': status_change,
13 13 'comment_file': comment_file,
14 14 'comment_line': comment_line,
15 'comment_type': comment_type,
15 16 }
16 17 %>
17 18
18 19 ${_('[mention]') if mention else ''} \
19 20
20 21 % if comment_file:
21 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s" (file: `%(comment_file)s`)') % data |n}
22 ${_('%(user)s left %(comment_type)s on pull request #%(pr_id)s "%(pr_title)s" (file: `%(comment_file)s`)') % data |n}
22 23 % else:
23 24 % if status_change:
24 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s" (status: %(status)s)') % data |n}
25 ${_('%(user)s left %(comment_type)s on pull request #%(pr_id)s "%(pr_title)s" (status: %(status)s)') % data |n}
25 26 % else:
26 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s"') % data |n}
27 ${_('%(user)s left %(comment_type)s on pull request #%(pr_id)s "%(pr_title)s"') % data |n}
27 28 % endif
28 29 % endif
29 30 </%def>
30 31
32 ## PLAINTEXT VERSION OF BODY
31 33 <%def name="body_plaintext()" filter="n,trim">
32 34 <%
33 35 data = {
34 36 'user': h.person(user),
35 37 'pr_title': pull_request.title,
36 38 'pr_id': pull_request.pull_request_id,
37 39 'status': status_change,
38 40 'comment_file': comment_file,
39 41 'comment_line': comment_line,
42 'comment_type': comment_type,
40 43 }
41 44 %>
42 45 ${self.subject()}
43 46
44 47 * ${_('Comment link')}: ${pr_comment_url}
45 48
46 49 * ${_('Source repository')}: ${pr_source_repo_url}
47 50
48 51 %if comment_file:
49 52 * ${_('File: %(comment_file)s on line %(comment_line)s') % {'comment_file': comment_file, 'comment_line': comment_line}}
50 53 %endif
51 54
52 55 ---
53 56
54 57 %if status_change and not closing_pr:
55 58 ${_('%(user)s submitted pull request #%(pr_id)s status: *%(status)s*') % data}
56 59 %elif status_change and closing_pr:
57 60 ${_('%(user)s submitted pull request #%(pr_id)s status: *%(status)s and closed*') % data}
58 61 %endif
59 62
60 63 ${comment_body|n}
61 64
62 65 ${self.plaintext_footer()}
63 66 </%def>
64 67
65 68
66 69 <%
67 70 data = {
68 71 'user': h.person(user),
69 72 'pr_title': pull_request.title,
70 73 'pr_id': pull_request.pull_request_id,
71 74 'status': status_change,
72 75 'comment_file': comment_file,
73 76 'comment_line': comment_line,
77 'comment_type': comment_type,
74 78 }
75 79 %>
76 80 <table style="text-align:left;vertical-align:middle;">
77 81 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;">
78 <h4><a href="${pr_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">
79 82
80 83 % if comment_file:
81 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s" (file:`%(comment_file)s`)') % data |n}
84 <h4><a href="${pr_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s" (file:`%(comment_file)s`)') % data |n}</a></h4>
82 85 % else:
83 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s"') % data |n}
86 <h4><a href="${pr_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s"') % data |n}</a></h4>
84 87 % endif
85 </a>
86 %if status_change and not closing_pr:
87 , ${_('submitted pull request status: %(status)s') % data}
88 %elif status_change and closing_pr:
89 , ${_('submitted pull request status: %(status)s and closed') % data}
90 %endif
91 </h4>
88
92 89 </td></tr>
93 90 <tr><td style="padding-right:20px;padding-top:15px;">${_('Source')}</td><td style="padding-top:15px;"><a style="color:#427cc9;text-decoration:none;cursor:pointer" href="${pr_source_repo_url}">${pr_source_repo.repo_name}</a></td></tr>
91
94 92 % if status_change:
95 <tr><td style="padding-right:20px;">${_('Submitted status')}</td><td>${base.status_text(status_change, tag_type=status_change_type)}</td></tr>
93 <tr>
94 <td style="padding-right:20px;">${_('Status')}</td>
95 <td>
96 % if closing_pr:
97 ${_('Closed pull request with status')}: ${base.status_text(status_change, tag_type=status_change_type)}
98 % else:
99 ${_('Submitted review status')}: ${base.status_text(status_change, tag_type=status_change_type)}
100 % endif
101 </td>
102 </tr>
96 103 % endif
97 <tr><td style="padding-right:20px;">${(_('Comment on line: %(comment_line)s') if comment_file else _('Comment')) % data}</td><td style="line-height:1.2em;white-space:pre-wrap">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td></tr>
104 <tr>
105 <td style="padding-right:20px;">
106 % if comment_type == 'todo':
107 ${(_('TODO comment on line: %(comment_line)s') if comment_file else _('TODO comment')) % data}
108 % else:
109 ${(_('Note comment on line: %(comment_line)s') if comment_file else _('Note comment')) % data}
110 % endif
111 </td>
112 <td style="line-height:1.2em;white-space:pre-wrap">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td>
113 </tr>
98 114 </table>
@@ -1,283 +1,288 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from pylons.i18n import ungettext
22 22 import pytest
23 23
24 24 from rhodecode.tests import *
25 25 from rhodecode.model.db import (
26 26 ChangesetComment, Notification, UserNotification)
27 27 from rhodecode.model.meta import Session
28 28 from rhodecode.lib import helpers as h
29 29
30 30
31 31 @pytest.mark.backends("git", "hg", "svn")
32 32 class TestCommitCommentsController(TestController):
33 33
34 34 @pytest.fixture(autouse=True)
35 35 def prepare(self, request, pylonsapp):
36 36 for x in ChangesetComment.query().all():
37 37 Session().delete(x)
38 38 Session().commit()
39 39
40 40 for x in Notification.query().all():
41 41 Session().delete(x)
42 42 Session().commit()
43 43
44 44 request.addfinalizer(self.cleanup)
45 45
46 46 def cleanup(self):
47 47 for x in ChangesetComment.query().all():
48 48 Session().delete(x)
49 49 Session().commit()
50 50
51 51 for x in Notification.query().all():
52 52 Session().delete(x)
53 53 Session().commit()
54 54
55 def test_create(self, backend):
55 @pytest.mark.parametrize('comment_type', ChangesetComment.COMMENT_TYPES)
56 def test_create(self, comment_type, backend):
56 57 self.log_user()
57 58 commit = backend.repo.get_commit('300')
58 59 commit_id = commit.raw_id
59 60 text = u'CommentOnCommit'
60 61
61 params = {'text': text, 'csrf_token': self.csrf_token}
62 params = {'text': text, 'csrf_token': self.csrf_token,
63 'comment_type': comment_type}
62 64 self.app.post(
63 65 url(controller='changeset', action='comment',
64 66 repo_name=backend.repo_name, revision=commit_id), params=params)
65 67
66 68 response = self.app.get(
67 69 url(controller='changeset', action='index',
68 70 repo_name=backend.repo_name, revision=commit_id))
69 71
70 72 # test DB
71 73 assert ChangesetComment.query().count() == 1
72 74 assert_comment_links(response, ChangesetComment.query().count(), 0)
73 75
74 76 assert Notification.query().count() == 1
75 77 assert ChangesetComment.query().count() == 1
76 78
77 79 notification = Notification.query().all()[0]
78 80
79 81 comment_id = ChangesetComment.query().first().comment_id
80 82 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
81 83
82 sbj = 'commented on commit `{0}` in the {1} repository'.format(
83 h.show_id(commit), backend.repo_name)
84 sbj = 'left {0} on commit `{1}` in the {2} repository'.format(
85 comment_type, h.show_id(commit), backend.repo_name)
84 86 assert sbj in notification.subject
85 87
86 88 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
87 89 backend.repo_name, commit_id, comment_id))
88 90 assert lnk in notification.body
89 91
90 def test_create_inline(self, backend):
92 @pytest.mark.parametrize('comment_type', ChangesetComment.COMMENT_TYPES)
93 def test_create_inline(self, comment_type, backend):
91 94 self.log_user()
92 95 commit = backend.repo.get_commit('300')
93 96 commit_id = commit.raw_id
94 97 text = u'CommentOnCommit'
95 98 f_path = 'vcs/web/simplevcs/views/repository.py'
96 99 line = 'n1'
97 100
98 101 params = {'text': text, 'f_path': f_path, 'line': line,
102 'comment_type': comment_type,
99 103 'csrf_token': self.csrf_token}
100 104
101 105 self.app.post(
102 106 url(controller='changeset', action='comment',
103 107 repo_name=backend.repo_name, revision=commit_id), params=params)
104 108
105 109 response = self.app.get(
106 110 url(controller='changeset', action='index',
107 111 repo_name=backend.repo_name, revision=commit_id))
108 112
109 113 # test DB
110 114 assert ChangesetComment.query().count() == 1
111 115 assert_comment_links(response, 0, ChangesetComment.query().count())
112 116
113 117 if backend.alias == 'svn':
114 118 response.mustcontain(
115 119 '''data-f-path="vcs/commands/summary.py" '''
116 120 '''id="a_c--ad05457a43f8"'''
117 121 )
118 122 else:
119 123 response.mustcontain(
120 124 '''data-f-path="vcs/backends/hg.py" '''
121 125 '''id="a_c--9c390eb52cd6"'''
122 126 )
123 127
124 128 assert Notification.query().count() == 1
125 129 assert ChangesetComment.query().count() == 1
126 130
127 131 notification = Notification.query().all()[0]
128 132 comment = ChangesetComment.query().first()
129 133 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
130 134
131 135 assert comment.revision == commit_id
132 sbj = 'commented on commit `{commit}` ' \
136 sbj = 'left {comment_type} on commit `{commit}` ' \
133 137 '(file: `{f_path}`) in the {repo} repository'.format(
134 138 commit=h.show_id(commit),
135 f_path=f_path, line=line, repo=backend.repo_name)
139 f_path=f_path, line=line, repo=backend.repo_name,
140 comment_type=comment_type)
136 141 assert sbj in notification.subject
137 142
138 143 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
139 144 backend.repo_name, commit_id, comment.comment_id))
140 145 assert lnk in notification.body
141 146 assert 'on line n1' in notification.body
142 147
143 148 def test_create_with_mention(self, backend):
144 149 self.log_user()
145 150
146 151 commit_id = backend.repo.get_commit('300').raw_id
147 152 text = u'@test_regular check CommentOnCommit'
148 153
149 154 params = {'text': text, 'csrf_token': self.csrf_token}
150 155 self.app.post(
151 156 url(controller='changeset', action='comment',
152 157 repo_name=backend.repo_name, revision=commit_id), params=params)
153 158
154 159 response = self.app.get(
155 160 url(controller='changeset', action='index',
156 161 repo_name=backend.repo_name, revision=commit_id))
157 162 # test DB
158 163 assert ChangesetComment.query().count() == 1
159 164 assert_comment_links(response, ChangesetComment.query().count(), 0)
160 165
161 166 notification = Notification.query().one()
162 167
163 168 assert len(notification.recipients) == 2
164 169 users = [x.username for x in notification.recipients]
165 170
166 171 # test_regular gets notification by @mention
167 172 assert sorted(users) == [u'test_admin', u'test_regular']
168 173
169 174 def test_create_with_status_change(self, backend):
170 175 self.log_user()
171 176 commit = backend.repo.get_commit('300')
172 177 commit_id = commit.raw_id
173 178 text = u'CommentOnCommit'
174 179 f_path = 'vcs/web/simplevcs/views/repository.py'
175 180 line = 'n1'
176 181
177 182 params = {'text': text, 'changeset_status': 'approved',
178 183 'csrf_token': self.csrf_token}
179 184
180 185 self.app.post(
181 186 url(controller='changeset', action='comment',
182 187 repo_name=backend.repo_name, revision=commit_id), params=params)
183 188
184 189 response = self.app.get(
185 190 url(controller='changeset', action='index',
186 191 repo_name=backend.repo_name, revision=commit_id))
187 192
188 193 # test DB
189 194 assert ChangesetComment.query().count() == 1
190 195 assert_comment_links(response, ChangesetComment.query().count(), 0)
191 196
192 197 assert Notification.query().count() == 1
193 198 assert ChangesetComment.query().count() == 1
194 199
195 200 notification = Notification.query().all()[0]
196 201
197 202 comment_id = ChangesetComment.query().first().comment_id
198 203 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
199 204
200 sbj = 'commented on commit `{0}` (status: Approved) ' \
205 sbj = 'left note on commit `{0}` (status: Approved) ' \
201 206 'in the {1} repository'.format(
202 207 h.show_id(commit), backend.repo_name)
203 208 assert sbj in notification.subject
204 209
205 210 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
206 211 backend.repo_name, commit_id, comment_id))
207 212 assert lnk in notification.body
208 213
209 214 def test_delete(self, backend):
210 215 self.log_user()
211 216 commit_id = backend.repo.get_commit('300').raw_id
212 217 text = u'CommentOnCommit'
213 218
214 219 params = {'text': text, 'csrf_token': self.csrf_token}
215 220 self.app.post(
216 221 url(
217 222 controller='changeset', action='comment',
218 223 repo_name=backend.repo_name, revision=commit_id),
219 224 params=params)
220 225
221 226 comments = ChangesetComment.query().all()
222 227 assert len(comments) == 1
223 228 comment_id = comments[0].comment_id
224 229
225 230 self.app.post(
226 231 url(controller='changeset', action='delete_comment',
227 232 repo_name=backend.repo_name, comment_id=comment_id),
228 233 params={'_method': 'delete', 'csrf_token': self.csrf_token})
229 234
230 235 comments = ChangesetComment.query().all()
231 236 assert len(comments) == 0
232 237
233 238 response = self.app.get(
234 239 url(controller='changeset', action='index',
235 240 repo_name=backend.repo_name, revision=commit_id))
236 241 assert_comment_links(response, 0, 0)
237 242
238 243 @pytest.mark.parametrize('renderer, input, output', [
239 244 ('rst', 'plain text', '<p>plain text</p>'),
240 245 ('rst', 'header\n======', '<h1 class="title">header</h1>'),
241 246 ('rst', '*italics*', '<em>italics</em>'),
242 247 ('rst', '**bold**', '<strong>bold</strong>'),
243 248 ('markdown', 'plain text', '<p>plain text</p>'),
244 249 ('markdown', '# header', '<h1>header</h1>'),
245 250 ('markdown', '*italics*', '<em>italics</em>'),
246 251 ('markdown', '**bold**', '<strong>bold</strong>'),
247 252 ], ids=['rst-plain', 'rst-header', 'rst-italics', 'rst-bold', 'md-plain',
248 253 'md-header', 'md-italics', 'md-bold', ])
249 254 def test_preview(self, renderer, input, output, backend):
250 255 self.log_user()
251 256 params = {
252 257 'renderer': renderer,
253 258 'text': input,
254 259 'csrf_token': self.csrf_token
255 260 }
256 261 environ = {
257 262 'HTTP_X_PARTIAL_XHR': 'true'
258 263 }
259 264 response = self.app.post(
260 265 url(controller='changeset',
261 266 action='preview_comment',
262 267 repo_name=backend.repo_name),
263 268 params=params,
264 269 extra_environ=environ)
265 270
266 271 response.mustcontain(output)
267 272
268 273
269 274 def assert_comment_links(response, comments, inline_comments):
270 275 comments_text = ungettext("%d Commit comment",
271 276 "%d Commit comments", comments) % comments
272 277 if comments:
273 278 response.mustcontain('<a href="#comments">%s</a>,' % comments_text)
274 279 else:
275 280 response.mustcontain(comments_text)
276 281
277 282 inline_comments_text = ungettext("%d Inline Comment", "%d Inline Comments",
278 283 inline_comments) % inline_comments
279 284 if inline_comments:
280 285 response.mustcontain(
281 286 'id="inline-comments-counter">%s</' % inline_comments_text)
282 287 else:
283 288 response.mustcontain(inline_comments_text)
General Comments 0
You need to be logged in to leave comments. Login now