PageRenderTime 36ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/reviewboard/reviews/models.py

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