PageRenderTime 55ms CodeModel.GetById 23ms RepoModel.GetById 0ms app.codeStats 0ms

/reviewboard/reviews/models.py

http://reviewboard.googlecode.com/
Python | 1103 lines | 969 code | 58 blank | 76 comment | 23 complexity | c614ff7327bd6d38b2d85e3aae5098e0 MD5 | raw file
  1. import os
  2. import re
  3. from datetime import datetime
  4. from django.contrib.auth.models import User
  5. from django.core.urlresolvers import reverse
  6. from django.db import connection, models
  7. from django.db.models import Q, permalink
  8. from django.utils.html import escape
  9. from django.utils.safestring import mark_safe
  10. from django.utils.translation import ugettext_lazy as _
  11. from djblets.util.db import ConcurrencyManager
  12. from djblets.util.fields import ModificationTimestampField
  13. from djblets.util.misc import get_object_or_none
  14. from djblets.util.templatetags.djblets_images import crop_image, thumbnail
  15. from reviewboard.changedescs.models import ChangeDescription
  16. from reviewboard.diffviewer.models import DiffSet, DiffSetHistory, FileDiff
  17. from reviewboard.reviews.signals import review_request_published
  18. from reviewboard.reviews.errors import PermissionError
  19. from reviewboard.reviews.managers import ReviewRequestManager, ReviewManager
  20. from reviewboard.scmtools.errors import InvalidChangeNumberError
  21. from reviewboard.scmtools.models import Repository
  22. #the model for the summery only allows it to be 300 chars in length
  23. MAX_SUMMARY_LENGTH = 300
  24. def update_obj_with_changenum(obj, repository, changenum):
  25. """
  26. Utility helper to update a review request or draft from the
  27. specified changeset's contents on the server.
  28. """
  29. changeset = repository.get_scmtool().get_changeset(changenum)
  30. if not changeset:
  31. raise InvalidChangeNumberError()
  32. # If the SCM supports changesets, they should always include a number,
  33. # summary and description, parsed from the changeset description. Some
  34. # specialized systems may support the other fields, but we don't want to
  35. # clobber the user-entered values if they don't.
  36. obj.changenum = changenum
  37. obj.summary = changeset.summary
  38. obj.description = changeset.description
  39. if changeset.testing_done:
  40. obj.testing_done = changeset.testing_done
  41. if changeset.branch:
  42. obj.branch = changeset.branch
  43. if changeset.bugs_closed:
  44. obj.bugs_closed = ','.join(changeset.bugs_closed)
  45. def truncate(string, num):
  46. if len(string) > num:
  47. string = string[0:num]
  48. i = string.rfind('.')
  49. if i != -1:
  50. string = string[0:i + 1]
  51. return string
  52. class Group(models.Model):
  53. """
  54. A group of reviewers identified by a name. This is usually used to
  55. separate teams at a company or components of a project.
  56. Each group can have an e-mail address associated with it, sending
  57. all review requests and replies to that address.
  58. """
  59. name = models.SlugField(_("name"), max_length=64, blank=False, unique=True)
  60. display_name = models.CharField(_("display name"), max_length=64)
  61. mailing_list = models.EmailField(_("mailing list"), blank=True,
  62. help_text=_("The mailing list review requests and discussions "
  63. "are sent to."))
  64. users = models.ManyToManyField(User, blank=True,
  65. related_name="review_groups",
  66. verbose_name=_("users"))
  67. def __unicode__(self):
  68. return self.name
  69. @permalink
  70. def get_absolute_url(self):
  71. return ('reviewboard.reviews.views.group', None, {'name': self.name})
  72. class Meta:
  73. verbose_name = _("review group")
  74. ordering = ['name']
  75. class DefaultReviewer(models.Model):
  76. """
  77. A default reviewer entry automatically adds default reviewers to a
  78. review request when the diff modifies a file matching the ``file_regex``
  79. pattern specified.
  80. This is useful when different groups own different parts of a codebase.
  81. Adding DefaultReviewer entries ensures that the right people will always
  82. see the review request and discussions.
  83. A ``file_regex`` of ``".*"`` will add the specified reviewers by
  84. default for every review request.
  85. """
  86. name = models.CharField(_("name"), max_length=64)
  87. file_regex = models.CharField(_("file regex"), max_length=256,
  88. help_text=_("File paths are matched against this regular expression "
  89. "to determine if these reviewers should be added."))
  90. groups = models.ManyToManyField(Group, verbose_name=_("default groups"),
  91. blank=True)
  92. people = models.ManyToManyField(User, verbose_name=_("default people"),
  93. related_name="default_review_paths",
  94. blank=True)
  95. def __unicode__(self):
  96. return self.name
  97. class Screenshot(models.Model):
  98. """
  99. A screenshot associated with a review request.
  100. Like diffs, a screenshot can have comments associated with it.
  101. These comments are of type :model:`reviews.ScreenshotComment`.
  102. """
  103. caption = models.CharField(_("caption"), max_length=256, blank=True)
  104. draft_caption = models.CharField(_("draft caption"),
  105. max_length=256, blank=True)
  106. image = models.ImageField(_("image"),
  107. upload_to=os.path.join('uploaded', 'images',
  108. '%Y', '%m', '%d'))
  109. def get_thumbnail_url(self):
  110. """
  111. Returns the URL for the thumbnail, creating it if necessary.
  112. """
  113. return thumbnail(self.image)
  114. def thumb(self):
  115. """
  116. Creates a thumbnail of this screenshot and returns the HTML
  117. output embedding the thumbnail.
  118. """
  119. url = self.get_thumbnail_url()
  120. return mark_safe('<img src="%s" alt="%s" />' % (url, self.caption))
  121. thumb.allow_tags = True
  122. def __unicode__(self):
  123. return u"%s (%s)" % (self.caption, self.image)
  124. @permalink
  125. def get_absolute_url(self):
  126. try:
  127. review = self.review_request.all()[0]
  128. except IndexError:
  129. review = self.inactive_review_request.all()[0]
  130. return ('reviewboard.reviews.views.view_screenshot', None, {
  131. 'review_request_id': review.id,
  132. 'screenshot_id': self.id
  133. })
  134. class ReviewRequest(models.Model):
  135. """
  136. A review request.
  137. This is one of the primary models in Review Board. Most everything
  138. is associated with a review request.
  139. The ReviewRequest model contains detailed information on a review
  140. request. Some fields are user-modifiable, while some are used for
  141. internal state.
  142. """
  143. PENDING_REVIEW = "P"
  144. SUBMITTED = "S"
  145. DISCARDED = "D"
  146. STATUSES = (
  147. (PENDING_REVIEW, _('Pending Review')),
  148. (SUBMITTED, _('Submitted')),
  149. (DISCARDED, _('Discarded')),
  150. )
  151. submitter = models.ForeignKey(User, verbose_name=_("submitter"),
  152. related_name="review_requests")
  153. time_added = models.DateTimeField(_("time added"), default=datetime.now)
  154. last_updated = ModificationTimestampField(_("last updated"))
  155. status = models.CharField(_("status"), max_length=1, choices=STATUSES,
  156. db_index=True)
  157. public = models.BooleanField(_("public"), default=False)
  158. changenum = models.PositiveIntegerField(_("change number"), blank=True,
  159. null=True, db_index=True)
  160. repository = models.ForeignKey(Repository,
  161. related_name="review_requests",
  162. verbose_name=_("repository"))
  163. email_message_id = models.CharField(_("e-mail message ID"), max_length=255,
  164. blank=True, null=True)
  165. time_emailed = models.DateTimeField(_("time e-mailed"), null=True,
  166. default=None, blank=True)
  167. summary = models.CharField(_("summary"), max_length=300)
  168. description = models.TextField(_("description"), blank=True)
  169. testing_done = models.TextField(_("testing done"), blank=True)
  170. bugs_closed = models.CommaSeparatedIntegerField(_("bugs"),
  171. max_length=300, blank=True)
  172. diffset_history = models.ForeignKey(DiffSetHistory,
  173. related_name="review_request",
  174. verbose_name=_('diff set history'),
  175. blank=True)
  176. branch = models.CharField(_("branch"), max_length=300, blank=True)
  177. target_groups = models.ManyToManyField(
  178. Group,
  179. related_name="review_requests",
  180. verbose_name=_("target groups"),
  181. blank=True)
  182. target_people = models.ManyToManyField(
  183. User,
  184. verbose_name=_("target people"),
  185. related_name="directed_review_requests",
  186. blank=True)
  187. screenshots = models.ManyToManyField(
  188. Screenshot,
  189. related_name="review_request",
  190. verbose_name=_("screenshots"),
  191. blank=True)
  192. inactive_screenshots = models.ManyToManyField(Screenshot,
  193. verbose_name=_("inactive screenshots"),
  194. help_text=_("A list of screenshots that used to be but are no "
  195. "longer associated with this review request."),
  196. related_name="inactive_review_request",
  197. blank=True)
  198. changedescs = models.ManyToManyField(ChangeDescription,
  199. verbose_name=_("change descriptions"),
  200. related_name="review_request",
  201. blank=True)
  202. # Review-related information
  203. last_review_timestamp = models.DateTimeField(_("last review timestamp"),
  204. null=True, default=None,
  205. blank=True)
  206. shipit_count = models.IntegerField(_("ship-it count"), default=0,
  207. null=True)
  208. # Set this up with the ReviewRequestManager
  209. objects = ReviewRequestManager()
  210. def get_bug_list(self):
  211. """
  212. Returns a sorted list of bugs associated with this review request.
  213. """
  214. if self.bugs_closed == "":
  215. return []
  216. bugs = re.split(r"[, ]+", self.bugs_closed)
  217. # First try a numeric sort, to show the best results for the majority
  218. # case of bug trackers with numeric IDs. If that fails, sort
  219. # alphabetically.
  220. try:
  221. bugs.sort(cmp=lambda x,y: int(x) - int(y))
  222. except ValueError:
  223. bugs.sort()
  224. return bugs
  225. def get_new_reviews(self, user):
  226. """
  227. Returns any new reviews since the user last viewed the review request.
  228. """
  229. if user.is_authenticated():
  230. # If this ReviewRequest was queried using with_counts=True,
  231. # then we should know the new review count and can use this to
  232. # decide whether we have anything at all to show.
  233. if hasattr(self, "new_review_count") and self.new_review_count > 0:
  234. query = self.visits.filter(user=user)
  235. try:
  236. visit = query[0]
  237. return self.reviews.filter(
  238. public=True,
  239. timestamp__gt=visit.timestamp).exclude(user=user)
  240. except IndexError:
  241. # This visit doesn't exist, so bail.
  242. pass
  243. return self.reviews.get_empty_query_set()
  244. def add_default_reviewers(self):
  245. """
  246. Add default reviewers to this review request based on the diffset.
  247. This method goes through the DefaultReviewer objects in the database and
  248. adds any missing reviewers based on regular expression comparisons with
  249. the set of files in the diff.
  250. """
  251. if self.diffset_history.diffsets.count() != 1:
  252. return
  253. diffset = self.diffset_history.diffsets.get()
  254. people = set()
  255. groups = set()
  256. # TODO: This is kind of inefficient, and could maybe be optimized in
  257. # some fancy way. Certainly the most superficial optimization that
  258. # could be made would be to cache the compiled regexes somewhere.
  259. files = diffset.files.all()
  260. for default in DefaultReviewer.objects.all():
  261. regex = re.compile(default.file_regex)
  262. for filediff in files:
  263. if regex.match(filediff.source_file or filediff.dest_file):
  264. for person in default.people.all():
  265. people.add(person)
  266. for group in default.groups.all():
  267. groups.add(group)
  268. break
  269. existing_people = self.target_people.all()
  270. for person in people:
  271. if person not in existing_people:
  272. self.target_people.add(person)
  273. existing_groups = self.target_groups.all()
  274. for group in groups:
  275. if group not in existing_groups:
  276. self.target_groups.add(group)
  277. def get_public_reviews(self):
  278. """
  279. Returns all public top-level reviews for this review request.
  280. """
  281. return self.reviews.filter(public=True, base_reply_to__isnull=True)
  282. def update_from_changenum(self, changenum):
  283. """
  284. Updates this review request from the specified changeset's contents
  285. on the server.
  286. """
  287. update_obj_with_changenum(self, self.repository, changenum)
  288. def is_accessible_by(self, user):
  289. "Returns true if the user can read this review request"
  290. return self.public or self.is_mutable_by(user)
  291. def is_mutable_by(self, user):
  292. "Returns true if the user can modify this review request"
  293. return self.submitter == user or \
  294. user.has_perm('reviews.can_edit_reviewrequest')
  295. def get_draft(self, user=None):
  296. """
  297. Returns the draft of the review request. If a user is specified,
  298. than the draft will be returned only if owned by the user. Otherwise,
  299. None will be returned.
  300. """
  301. if not user:
  302. return get_object_or_none(self.draft)
  303. elif user.is_authenticated():
  304. return get_object_or_none(self.draft,
  305. review_request__submitter=user)
  306. return None
  307. def get_pending_review(self, user):
  308. """
  309. Returns the pending review owned by the specified user, if any.
  310. This will return an actual review, not a reply to a review.
  311. """
  312. return Review.objects.get_pending_review(self, user)
  313. @permalink
  314. def get_absolute_url(self):
  315. return ('review-request-detail', None, {
  316. 'review_request_id': self.id,
  317. })
  318. def __unicode__(self):
  319. return self.summary
  320. def save(self, **kwargs):
  321. self.bugs_closed = self.bugs_closed.strip()
  322. self.summary = truncate(self.summary, MAX_SUMMARY_LENGTH)
  323. if self.status != "P":
  324. # If this is not a pending review request now, delete any
  325. # and all ReviewRequestVisit objects.
  326. self.visits.all().delete()
  327. super(ReviewRequest, self).save()
  328. def can_publish(self):
  329. return not self.public or get_object_or_none(self.draft) is not None
  330. def close(self, type, user=None):
  331. """
  332. Closes the review request. The type must be one of
  333. SUBMITTED or DISCARDED.
  334. """
  335. if (user and not self.is_mutable_by(user) and
  336. not user.has_perm("reviews.can_change_status")):
  337. raise PermissionError
  338. if type not in [self.SUBMITTED, self.DISCARDED]:
  339. raise AttributeError("%s is not a valid close type" % type)
  340. self.status = type
  341. self.save()
  342. try:
  343. draft = self.draft.get()
  344. except ReviewRequestDraft.DoesNotExist:
  345. pass
  346. else:
  347. draft.delete()
  348. def reopen(self, user=None):
  349. """
  350. Reopens the review request for review.
  351. """
  352. if (user and not self.is_mutable_by(user) and
  353. not user.has_perm("reviews.can_change_status")):
  354. raise PermissionError
  355. if self.status != self.PENDING_REVIEW:
  356. if self.status == self.DISCARDED:
  357. self.public = False
  358. self.status = self.PENDING_REVIEW
  359. self.save()
  360. def update_changenum(self,changenum, user=None):
  361. if (user and not self.is_mutable_by(user)):
  362. raise PermissionError
  363. self.changenum = changenum
  364. self.save()
  365. def publish(self, user):
  366. """
  367. Save the current draft attached to this review request. Send out the
  368. associated email. Returns the review request that was saved.
  369. """
  370. if not self.is_mutable_by(user):
  371. raise PermissionError
  372. draft = get_object_or_none(self.draft)
  373. if draft is not None:
  374. # This will in turn save the review request, so we'll be done.
  375. changes = draft.publish(self)
  376. draft.delete()
  377. else:
  378. changes = None
  379. self.public = True
  380. self.save()
  381. review_request_published.send(sender=self, user=user,
  382. review_request=self,
  383. changedesc=changes)
  384. def increment_ship_it(self):
  385. """Atomicly increments the ship-it count on the review request."""
  386. # TODO: When we switch to Django 1.1, change this to:
  387. #
  388. # ReviewRequest.objects.filter(pk=self.id).update(
  389. # shipit_count=F('shipit_count') + 1)
  390. cursor = connection.cursor()
  391. cursor.execute("UPDATE reviews_reviewrequest"
  392. " SET shipit_count = shipit_count + 1"
  393. " WHERE id = %s",
  394. [self.id])
  395. # Update our copy.
  396. r = ReviewRequest.objects.get(pk=self.id)
  397. self.shipit_count = r.shipit_count
  398. class Meta:
  399. ordering = ['-last_updated', 'submitter', 'summary']
  400. unique_together = (('changenum', 'repository'),)
  401. permissions = (
  402. ("can_change_status", "Can change status"),
  403. ("can_submit_as_another_user", "Can submit as another user"),
  404. ("can_edit_reviewrequest", "Can edit review request"),
  405. )
  406. class ReviewRequestDraft(models.Model):
  407. """
  408. A draft of a review request.
  409. When a review request is being modified, a special draft copy of it is
  410. created containing all the details of the review request. This copy can
  411. be modified and eventually saved or discarded. When saved, the new
  412. details are copied back over to the originating ReviewRequest.
  413. """
  414. review_request = models.ForeignKey(ReviewRequest,
  415. related_name="draft",
  416. verbose_name=_("review request"),
  417. unique=True)
  418. last_updated = ModificationTimestampField(_("last updated"))
  419. summary = models.CharField(_("summary"), max_length=300)
  420. description = models.TextField(_("description"))
  421. testing_done = models.TextField(_("testing done"))
  422. bugs_closed = models.CommaSeparatedIntegerField(_("bugs"),
  423. max_length=300, blank=True)
  424. diffset = models.ForeignKey(DiffSet, verbose_name=_('diff set'),
  425. blank=True, null=True)
  426. changedesc = models.ForeignKey(ChangeDescription,
  427. verbose_name=_('change description'),
  428. blank=True, null=True)
  429. branch = models.CharField(_("branch"), max_length=300, blank=True)
  430. target_groups = models.ManyToManyField(Group,
  431. related_name="drafts",
  432. verbose_name=_("target groups"),
  433. blank=True)
  434. target_people = models.ManyToManyField(User,
  435. verbose_name=_("target people"),
  436. related_name="directed_drafts",
  437. blank=True)
  438. screenshots = models.ManyToManyField(Screenshot,
  439. related_name="drafts",
  440. verbose_name=_("screenshots"),
  441. blank=True)
  442. inactive_screenshots = models.ManyToManyField(Screenshot,
  443. verbose_name=_("inactive screenshots"),
  444. related_name="inactive_drafts",
  445. blank=True)
  446. submitter = property(lambda self: self.review_request.submitter)
  447. # Set this up with a ConcurrencyManager to help prevent race conditions.
  448. objects = ConcurrencyManager()
  449. def get_bug_list(self):
  450. """
  451. Returns a sorted list of bugs associated with this review request.
  452. """
  453. if self.bugs_closed == "":
  454. return []
  455. bugs = re.split(r"[, ]+", self.bugs_closed)
  456. # First try a numeric sort, to show the best results for the majority
  457. # case of bug trackers with numeric IDs. If that fails, sort
  458. # alphabetically.
  459. try:
  460. bugs.sort(cmp=lambda x,y: int(x) - int(y))
  461. except ValueError:
  462. bugs.sort()
  463. return bugs
  464. def __unicode__(self):
  465. return self.summary
  466. def save(self, **kwargs):
  467. self.bugs_closed = self.bugs_closed.strip()
  468. self.summary = truncate(self.summary, MAX_SUMMARY_LENGTH)
  469. super(ReviewRequestDraft, self).save()
  470. @staticmethod
  471. def create(review_request):
  472. """
  473. Creates a draft based on a review request.
  474. This will copy over all the details of the review request that
  475. we care about. If a draft already exists for the review request,
  476. the draft will be returned.
  477. """
  478. draft, draft_is_new = \
  479. ReviewRequestDraft.objects.get_or_create(
  480. review_request=review_request,
  481. defaults={
  482. 'summary': review_request.summary,
  483. 'description': review_request.description,
  484. 'testing_done': review_request.testing_done,
  485. 'bugs_closed': review_request.bugs_closed,
  486. 'branch': review_request.branch,
  487. })
  488. if draft.changedesc is None and review_request.public:
  489. changedesc = ChangeDescription()
  490. changedesc.save()
  491. draft.changedesc = changedesc
  492. if draft_is_new:
  493. map(draft.target_groups.add, review_request.target_groups.all())
  494. map(draft.target_people.add, review_request.target_people.all())
  495. for screenshot in review_request.screenshots.all():
  496. screenshot.draft_caption = screenshot.caption
  497. screenshot.save()
  498. draft.screenshots.add(screenshot)
  499. for screenshot in review_request.inactive_screenshots.all():
  500. screenshot.draft_caption = screenshot.caption
  501. screenshot.save()
  502. draft.inactive_screenshots.add(screenshot)
  503. draft.save();
  504. return draft
  505. def add_default_reviewers(self):
  506. """
  507. Add default reviewers to this draft based on the diffset.
  508. This method goes through the DefaultReviewer objects in the database and
  509. adds any missing reviewers based on regular expression comparisons with
  510. the set of files in the diff.
  511. """
  512. if not self.diffset:
  513. return
  514. people = set()
  515. groups = set()
  516. # TODO: This is kind of inefficient, and could maybe be optimized in
  517. # some fancy way. Certainly the most superficial optimization that
  518. # could be made would be to cache the compiled regexes somewhere.
  519. files = self.diffset.files.all()
  520. for default in DefaultReviewer.objects.all():
  521. try:
  522. regex = re.compile(default.file_regex)
  523. except:
  524. continue
  525. for filediff in files:
  526. if regex.match(filediff.source_file or filediff.dest_file):
  527. for person in default.people.all():
  528. people.add(person)
  529. for group in default.groups.all():
  530. groups.add(group)
  531. break
  532. existing_people = self.target_people.all()
  533. for person in people:
  534. if person not in existing_people:
  535. self.target_people.add(person)
  536. existing_groups = self.target_groups.all()
  537. for group in groups:
  538. if group not in existing_groups:
  539. self.target_groups.add(group)
  540. def publish(self, review_request=None):
  541. """
  542. Publishes this draft. Uses the draft's assocated ReviewRequest
  543. object if one isn't passed in.
  544. This updates and returns the draft's ChangeDescription, which
  545. contains the changed fields. This is used by the e-mail template
  546. to tell people what's new and interesting.
  547. The keys that may be saved in 'fields_changed' in the
  548. ChangeDescription are:
  549. * 'summary'
  550. * 'description'
  551. * 'testing_done'
  552. * 'bugs_closed'
  553. * 'branch'
  554. * 'target_groups'
  555. * 'target_people'
  556. * 'screenshots'
  557. * 'screenshot_captions'
  558. * 'diff'
  559. Each field in 'fields_changed' represents a changed field. This will
  560. save fields in the standard formats as defined by the
  561. 'ChangeDescription' documentation, with the exception of the
  562. 'screenshot_captions' and 'diff' fields.
  563. For the 'screenshot_captions' field, the value will be a dictionary
  564. of screenshot ID/dict pairs with the following fields:
  565. * 'old': The old value of the field
  566. * 'new': The new value of the field
  567. For the 'diff' field, there is only ever an 'added' field, containing
  568. the ID of the new diffset.
  569. """
  570. if not review_request:
  571. review_request = self.review_request
  572. if not self.changedesc and review_request.public:
  573. self.changedesc = ChangeDescription()
  574. def update_field(a, b, name, record_changes=True):
  575. # Apparently django models don't have __getattr__ or __setattr__,
  576. # so we have to update __dict__ directly. Sigh.
  577. value = b.__dict__[name]
  578. old_value = a.__dict__[name]
  579. if old_value != value:
  580. if record_changes and self.changedesc:
  581. self.changedesc.record_field_change(name, old_value, value)
  582. a.__dict__[name] = value
  583. def update_list(a, b, name, record_changes=True, name_field=None):
  584. aset = set([x.id for x in a.all()])
  585. bset = set([x.id for x in b.all()])
  586. if aset.symmetric_difference(bset):
  587. if record_changes and self.changedesc:
  588. self.changedesc.record_field_change(name, a.all(), b.all(),
  589. name_field)
  590. a.clear()
  591. map(a.add, b.all())
  592. update_field(review_request, self, 'summary')
  593. update_field(review_request, self, 'description')
  594. update_field(review_request, self, 'testing_done')
  595. update_field(review_request, self, 'branch')
  596. update_list(review_request.target_groups, self.target_groups,
  597. 'target_groups', name_field="name")
  598. update_list(review_request.target_people, self.target_people,
  599. 'target_people', name_field="username")
  600. # Specifically handle bug numbers
  601. old_bugs = set(review_request.get_bug_list())
  602. new_bugs = set(self.get_bug_list())
  603. if old_bugs != new_bugs:
  604. update_field(review_request, self, 'bugs_closed',
  605. record_changes=False)
  606. if self.changedesc:
  607. self.changedesc.record_field_change('bugs_closed',
  608. old_bugs - new_bugs,
  609. new_bugs - old_bugs)
  610. # Screenshots are a bit special. The list of associated screenshots can
  611. # change, but so can captions within each screenshot.
  612. screenshots = self.screenshots.all()
  613. caption_changes = {}
  614. for s in review_request.screenshots.all():
  615. if s in screenshots and s.caption != s.draft_caption:
  616. caption_changes[s.id] = {
  617. 'old': (s.caption,),
  618. 'new': (s.draft_caption,),
  619. }
  620. s.caption = s.draft_caption
  621. s.save()
  622. if caption_changes and self.changedesc:
  623. self.changedesc.fields_changed['screenshot_captions'] = \
  624. caption_changes
  625. update_list(review_request.screenshots, self.screenshots,
  626. 'screenshots', name_field="caption")
  627. # There's no change notification required for this field.
  628. review_request.inactive_screenshots.clear()
  629. map(review_request.inactive_screenshots.add,
  630. self.inactive_screenshots.all())
  631. if self.diffset:
  632. if self.changedesc:
  633. self.changedesc.fields_changed['diff'] = {
  634. 'added': [(_("Diff r%s") % self.diffset.revision,
  635. reverse("view_diff_revision",
  636. args=[review_request.id,
  637. self.diffset.revision]),
  638. self.diffset.id)],
  639. }
  640. self.diffset.history = review_request.diffset_history
  641. self.diffset.save()
  642. if self.changedesc:
  643. self.changedesc.timestamp = datetime.now()
  644. self.changedesc.public = True
  645. self.changedesc.save()
  646. review_request.changedescs.add(self.changedesc)
  647. review_request.save()
  648. return self.changedesc
  649. def update_from_changenum(self, changenum):
  650. """
  651. Updates this draft from the specified changeset's contents on
  652. the server.
  653. """
  654. update_obj_with_changenum(self, self.review_request.repository,
  655. changenum)
  656. class Meta:
  657. ordering = ['-last_updated']
  658. class Comment(models.Model):
  659. """
  660. A comment made on a diff.
  661. A comment can belong to a single filediff or to an interdiff between
  662. two filediffs. It can also have multiple replies.
  663. """
  664. filediff = models.ForeignKey(FileDiff, verbose_name=_('file diff'),
  665. related_name="comments")
  666. interfilediff = models.ForeignKey(FileDiff,
  667. verbose_name=_('interdiff file'),
  668. blank=True, null=True,
  669. related_name="interdiff_comments")
  670. reply_to = models.ForeignKey("self", blank=True, null=True,
  671. related_name="replies",
  672. verbose_name=_("reply to"))
  673. timestamp = models.DateTimeField(_('timestamp'), default=datetime.now)
  674. text = models.TextField(_("comment text"))
  675. # A null line number applies to an entire diff. Non-null line numbers are
  676. # the line within the entire file, starting at 1.
  677. first_line = models.PositiveIntegerField(_("first line"), blank=True,
  678. null=True)
  679. num_lines = models.PositiveIntegerField(_("number of lines"), blank=True,
  680. null=True)
  681. last_line = property(lambda self: self.first_line + self.num_lines - 1)
  682. # Set this up with a ConcurrencyManager to help prevent race conditions.
  683. objects = ConcurrencyManager()
  684. def public_replies(self, user=None):
  685. """
  686. Returns a list of public replies to this comment, optionally
  687. specifying the user replying.
  688. """
  689. if user:
  690. return self.replies.filter(Q(review__public=True) |
  691. Q(review__user=user))
  692. else:
  693. return self.replies.filter(review__public=True)
  694. def get_absolute_url(self):
  695. revision_path = str(self.filediff.diffset.revision)
  696. if self.interfilediff:
  697. revision_path += "-%s" % self.interfilediff.diffset.revision
  698. return "%sdiff/%s/?file=%s#file%sline%s" % \
  699. (self.review.get().review_request.get_absolute_url(),
  700. revision_path, self.filediff.id, self.filediff.id,
  701. self.first_line)
  702. def get_review_url(self):
  703. return "%s#comment%d" % \
  704. (self.review.get().review_request.get_absolute_url(), self.id)
  705. def save(self, **kwargs):
  706. super(Comment, self).save()
  707. try:
  708. # Update the review timestamp.
  709. review = self.review.get()
  710. review.timestamp = datetime.now()
  711. review.save()
  712. except Review.DoesNotExist:
  713. pass
  714. def __unicode__(self):
  715. return self.text
  716. def truncate_text(self):
  717. if len(self.text) > 60:
  718. return self.text[0:57] + "..."
  719. else:
  720. return self.text
  721. class Meta:
  722. ordering = ['timestamp']
  723. class ScreenshotComment(models.Model):
  724. """
  725. A comment on a screenshot.
  726. """
  727. screenshot = models.ForeignKey(Screenshot, verbose_name=_('screenshot'),
  728. related_name="comments")
  729. reply_to = models.ForeignKey('self', blank=True, null=True,
  730. related_name='replies',
  731. verbose_name=_("reply to"))
  732. timestamp = models.DateTimeField(_('timestamp'), default=datetime.now)
  733. text = models.TextField(_('comment text'))
  734. # This is a sub-region of the screenshot. Null X indicates the entire
  735. # image.
  736. x = models.PositiveSmallIntegerField(_("sub-image X"), null=True)
  737. y = models.PositiveSmallIntegerField(_("sub-image Y"))
  738. w = models.PositiveSmallIntegerField(_("sub-image width"))
  739. h = models.PositiveSmallIntegerField(_("sub-image height"))
  740. # Set this up with a ConcurrencyManager to help prevent race conditions.
  741. objects = ConcurrencyManager()
  742. def public_replies(self, user=None):
  743. """
  744. Returns a list of public replies to this comment, optionally
  745. specifying the user replying.
  746. """
  747. if user:
  748. return self.replies.filter(Q(review__public=True) |
  749. Q(review__user=user))
  750. else:
  751. return self.replies.filter(review__public=True)
  752. def get_image_url(self):
  753. """
  754. Returns the URL for the thumbnail, creating it if necessary.
  755. """
  756. return crop_image(self.screenshot.image, self.x, self.y, self.w, self.h)
  757. def image(self):
  758. """
  759. Generates the cropped part of the screenshot referenced by this
  760. comment and returns the HTML markup embedding it.
  761. """
  762. return '<img src="%s" width="%s" height="%s" alt="%s" />' % \
  763. (self.get_image_url(), self.w, self.h, escape(self.text))
  764. def get_review_url(self):
  765. return "%s#scomment%d" % \
  766. (self.review.get().review_request.get_absolute_url(), self.id)
  767. def save(self, **kwargs):
  768. super(ScreenshotComment, self).save()
  769. try:
  770. # Update the review timestamp.
  771. review = self.review.get()
  772. review.timestamp = datetime.now()
  773. review.save()
  774. except Review.DoesNotExist:
  775. pass
  776. def __unicode__(self):
  777. return self.text
  778. class Meta:
  779. ordering = ['timestamp']
  780. class Review(models.Model):
  781. """
  782. A review of a review request.
  783. """
  784. review_request = models.ForeignKey(ReviewRequest,
  785. related_name="reviews",
  786. verbose_name=_("review request"))
  787. user = models.ForeignKey(User, verbose_name=_("user"),
  788. related_name="reviews")
  789. timestamp = models.DateTimeField(_('timestamp'), default=datetime.now)
  790. public = models.BooleanField(_("public"), default=False)
  791. ship_it = models.BooleanField(_("ship it"), default=False,
  792. help_text=_("Indicates whether the reviewer thinks this code is "
  793. "ready to ship."))
  794. base_reply_to = models.ForeignKey(
  795. "self", blank=True, null=True,
  796. related_name="replies",
  797. verbose_name=_("Base reply to"),
  798. help_text=_("The top-most review in the discussion thread for "
  799. "this review reply."))
  800. email_message_id = models.CharField(_("e-mail message ID"), max_length=255,
  801. blank=True, null=True)
  802. time_emailed = models.DateTimeField(_("time e-mailed"), null=True,
  803. default=None, blank=True)
  804. body_top = models.TextField(_("body (top)"), blank=True,
  805. help_text=_("The review text shown above the diff and screenshot "
  806. "comments."))
  807. body_bottom = models.TextField(_("body (bottom)"), blank=True,
  808. help_text=_("The review text shown below the diff and screenshot "
  809. "comments."))
  810. body_top_reply_to = models.ForeignKey(
  811. "self", blank=True, null=True,
  812. related_name="body_top_replies",
  813. verbose_name=_("body (top) reply to"),
  814. help_text=_("The review that the body (top) field is in reply to."))
  815. body_bottom_reply_to = models.ForeignKey(
  816. "self", blank=True, null=True,
  817. related_name="body_bottom_replies",
  818. verbose_name=_("body (bottom) reply to"),
  819. help_text=_("The review that the body (bottom) field is in reply to."))
  820. comments = models.ManyToManyField(Comment, verbose_name=_("comments"),
  821. related_name="review", blank=True)
  822. screenshot_comments = models.ManyToManyField(
  823. ScreenshotComment,
  824. verbose_name=_("screenshot comments"),
  825. related_name="review",
  826. blank=True)
  827. # XXX Deprecated. This will be removed in a future release.
  828. reviewed_diffset = models.ForeignKey(
  829. DiffSet, verbose_name="Reviewed Diff",
  830. blank=True, null=True,
  831. help_text=_("This field is unused and will be removed in a future "
  832. "version."))
  833. # Set this up with a ReviewManager to help prevent race conditions and
  834. # to fix duplicate reviews.
  835. objects = ReviewManager()
  836. def __unicode__(self):
  837. return u"Review of '%s'" % self.review_request
  838. def is_reply(self):
  839. """
  840. Returns whether or not this review is a reply to another review.
  841. """
  842. return self.base_reply_to != None
  843. is_reply.boolean = True
  844. def public_replies(self):
  845. """
  846. Returns a list of public replies to this review.
  847. """
  848. return self.replies.filter(public=True)
  849. def get_pending_reply(self, user):
  850. """
  851. Returns the pending reply to this review owned by the specified
  852. user, if any.
  853. """
  854. if user.is_authenticated():
  855. return get_object_or_none(Review,
  856. user=user,
  857. public=False,
  858. base_reply_to=self)
  859. return None
  860. def save(self, **kwargs):
  861. self.timestamp = datetime.now()
  862. super(Review, self).save()
  863. def publish(self):
  864. """
  865. Publishes this review.
  866. This will make the review public and update the timestamps of all
  867. contained comments.
  868. """
  869. self.public = True
  870. self.save()
  871. for comment in self.comments.all():
  872. comment.timetamp = self.timestamp
  873. comment.save()
  874. for comment in self.screenshot_comments.all():
  875. comment.timetamp = self.timestamp
  876. comment.save()
  877. # Update the last_updated timestamp on the review request.
  878. self.review_request.last_review_timestamp = self.timestamp
  879. self.review_request.save()
  880. # Atomicly update the shipit_count
  881. if self.ship_it:
  882. self.review_request.increment_ship_it()
  883. def delete(self):
  884. """
  885. Deletes this review.
  886. This will enforce that all contained comments are also deleted.
  887. """
  888. for comment in self.comments.all():
  889. comment.delete()
  890. for comment in self.screenshot_comments.all():
  891. comment.delete()
  892. super(Review, self).delete()
  893. def get_absolute_url(self):
  894. return "%s#review%s" % (self.review_request.get_absolute_url(),
  895. self.id)
  896. class Meta:
  897. ordering = ['timestamp']
  898. get_latest_by = 'timestamp'