PageRenderTime 57ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/reviewboard/reviews/models.py

http://github.com/reviewboard/reviewboard
Python | 1953 lines | 1813 code | 59 blank | 81 comment | 22 complexity | a016d731b5095668938beef3a1d56a0b MD5 | raw file
Possible License(s): GPL-2.0
  1. import os
  2. import re
  3. from django.contrib.auth.models import User
  4. from django.db import models
  5. from django.db.models import Q
  6. from django.utils import timezone
  7. from django.utils.html import escape
  8. from django.utils.safestring import mark_safe
  9. from django.utils.translation import ugettext_lazy as _
  10. from djblets.util.db import ConcurrencyManager
  11. from djblets.util.fields import (CounterField, JSONField,
  12. 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.attachments.models import FileAttachment
  18. from reviewboard.reviews.errors import PermissionError
  19. from reviewboard.reviews.managers import (DefaultReviewerManager,
  20. ReviewGroupManager,
  21. ReviewRequestManager,
  22. ReviewManager)
  23. from reviewboard.reviews.signals import (review_request_published,
  24. review_request_reopened,
  25. review_request_closed,
  26. reply_published, review_published)
  27. from reviewboard.scmtools.errors import InvalidChangeNumberError
  28. from reviewboard.scmtools.models import Repository
  29. from reviewboard.site.models import LocalSite
  30. from reviewboard.site.urlresolvers import local_site_reverse
  31. class Group(models.Model):
  32. """
  33. A group of reviewers identified by a name. This is usually used to
  34. separate teams at a company or components of a project.
  35. Each group can have an e-mail address associated with it, sending
  36. all review requests and replies to that address. If that e-mail address is
  37. blank, e-mails are sent individually to each member of that group.
  38. """
  39. name = models.SlugField(_("name"), max_length=64, blank=False)
  40. display_name = models.CharField(_("display name"), max_length=64)
  41. mailing_list = models.EmailField(
  42. _("mailing list"),
  43. blank=True,
  44. help_text=_("The mailing list review requests and discussions "
  45. "are sent to."))
  46. users = models.ManyToManyField(User, blank=True,
  47. related_name="review_groups",
  48. verbose_name=_("users"))
  49. local_site = models.ForeignKey(LocalSite, blank=True, null=True)
  50. incoming_request_count = CounterField(
  51. _('incoming review request count'),
  52. initializer=lambda g: ReviewRequest.objects.to_group(
  53. g, local_site=g.local_site).count())
  54. invite_only = models.BooleanField(_('invite only'), default=False)
  55. visible = models.BooleanField(default=True)
  56. objects = ReviewGroupManager()
  57. def is_accessible_by(self, user):
  58. "Returns true if the user can access this group."""
  59. if self.local_site and not self.local_site.is_accessible_by(user):
  60. return False
  61. return (not self.invite_only or
  62. user.is_superuser or
  63. (user.is_authenticated() and
  64. self.users.filter(pk=user.pk).count() > 0))
  65. def is_mutable_by(self, user):
  66. """
  67. Returns whether or not the user can modify or delete the group.
  68. The group is mutable by the user if they are an administrator with
  69. proper permissions, or the group is part of a LocalSite and the user is
  70. in the admin list.
  71. """
  72. return (user.has_perm('reviews.change_group') or
  73. (self.local_site and self.local_site.is_mutable_by(user)))
  74. def __unicode__(self):
  75. return self.name
  76. def get_absolute_url(self):
  77. if self.local_site_id:
  78. local_site_name = self.local_site.name
  79. else:
  80. local_site_name = None
  81. return local_site_reverse('group', local_site_name=local_site_name,
  82. kwargs={'name': self.name})
  83. class Meta:
  84. unique_together = (('name', 'local_site'),)
  85. verbose_name = _("review group")
  86. ordering = ['name']
  87. class DefaultReviewer(models.Model):
  88. """
  89. A default reviewer entry automatically adds default reviewers to a
  90. review request when the diff modifies a file matching the ``file_regex``
  91. pattern specified.
  92. This is useful when different groups own different parts of a codebase.
  93. Adding DefaultReviewer entries ensures that the right people will always
  94. see the review request and discussions.
  95. A ``file_regex`` of ``".*"`` will add the specified reviewers by
  96. default for every review request.
  97. Note that this is keyed off the same LocalSite as its "repository" member.
  98. """
  99. name = models.CharField(_("name"), max_length=64)
  100. file_regex = models.CharField(
  101. _("file regex"),
  102. max_length=256,
  103. help_text=_("File paths are matched against this regular expression "
  104. "to determine if these reviewers should be added."))
  105. repository = models.ManyToManyField(Repository, blank=True)
  106. groups = models.ManyToManyField(Group, verbose_name=_("default groups"),
  107. blank=True)
  108. people = models.ManyToManyField(User, verbose_name=_("default people"),
  109. related_name="default_review_paths",
  110. blank=True)
  111. local_site = models.ForeignKey(LocalSite, blank=True, null=True,
  112. related_name='default_reviewers')
  113. objects = DefaultReviewerManager()
  114. def is_accessible_by(self, user):
  115. "Returns whether the user can access this default reviewer."""
  116. if self.local_site and not self.local_site.is_accessible_by(user):
  117. return False
  118. return True
  119. def is_mutable_by(self, user):
  120. """Returns whether the user can modify or delete this default reviewer.
  121. Only those with the default_reviewer.change_group permission (such as
  122. administrators) can modify or delete default reviewers not bound
  123. to a LocalSite.
  124. LocalSite administrators can modify or delete them on their LocalSites.
  125. """
  126. return (user.has_perm('reviews.change_default_reviewer') or
  127. (self.local_site and self.local_site.is_mutable_by(user)))
  128. def __unicode__(self):
  129. return self.name
  130. class Screenshot(models.Model):
  131. """
  132. A screenshot associated with a review request.
  133. Like diffs, a screenshot can have comments associated with it.
  134. These comments are of type :model:`reviews.ScreenshotComment`.
  135. """
  136. caption = models.CharField(_("caption"), max_length=256, blank=True)
  137. draft_caption = models.CharField(_("draft caption"),
  138. max_length=256, blank=True)
  139. image = models.ImageField(_("image"),
  140. upload_to=os.path.join('uploaded', 'images',
  141. '%Y', '%m', '%d'))
  142. def get_comments(self):
  143. """Returns all the comments made on this screenshot."""
  144. if not hasattr(self, '_comments'):
  145. self._comments = list(self.comments.all())
  146. return self._comments
  147. def get_thumbnail_url(self):
  148. """
  149. Returns the URL for the thumbnail, creating it if necessary.
  150. """
  151. return thumbnail(self.image)
  152. def thumb(self):
  153. """
  154. Creates a thumbnail of this screenshot and returns the HTML
  155. output embedding the thumbnail.
  156. """
  157. url = self.get_thumbnail_url()
  158. return mark_safe('<img src="%s" data-at2x="%s" alt="%s" />' %
  159. (url, thumbnail(self.image, '800x200'),
  160. self.caption))
  161. thumb.allow_tags = True
  162. def __unicode__(self):
  163. return u"%s (%s)" % (self.caption, self.image)
  164. def get_review_request(self):
  165. if hasattr(self, '_review_request'):
  166. return self._review_request
  167. try:
  168. return self.review_request.all()[0]
  169. except IndexError:
  170. try:
  171. return self.inactive_review_request.all()[0]
  172. except IndexError:
  173. # Maybe it's on a draft.
  174. try:
  175. draft = self.drafts.get()
  176. except ReviewRequestDraft.DoesNotExist:
  177. draft = self.inactive_drafts.get()
  178. return draft.review_request
  179. def get_absolute_url(self):
  180. review_request = self.get_review_request()
  181. if review_request.local_site:
  182. local_site_name = review_request.local_site.name
  183. else:
  184. local_site_name = None
  185. return local_site_reverse(
  186. 'screenshot',
  187. local_site_name=local_site_name,
  188. kwargs={
  189. 'review_request_id': review_request.display_id,
  190. 'screenshot_id': self.pk,
  191. })
  192. def save(self, **kwargs):
  193. super(Screenshot, self).save()
  194. try:
  195. draft = self.drafts.get()
  196. draft.timestamp = timezone.now()
  197. draft.save()
  198. except ReviewRequestDraft.DoesNotExist:
  199. pass
  200. class BaseReviewRequestDetails(models.Model):
  201. """Base information for a review request and draft.
  202. ReviewRequest and ReviewRequestDraft share a lot of fields and
  203. methods. This class provides those fields and methods for those
  204. classes.
  205. """
  206. MAX_SUMMARY_LENGTH = 300
  207. summary = models.CharField(_("summary"), max_length=MAX_SUMMARY_LENGTH)
  208. description = models.TextField(_("description"), blank=True)
  209. testing_done = models.TextField(_("testing done"), blank=True)
  210. bugs_closed = models.CharField(_("bugs"), max_length=300, blank=True)
  211. branch = models.CharField(_("branch"), max_length=300, blank=True)
  212. rich_text = models.BooleanField(_("rich text"), default=True)
  213. def _get_review_request(self):
  214. raise NotImplementedError
  215. def get_bug_list(self):
  216. """
  217. Returns a sorted list of bugs associated with this review request.
  218. """
  219. if self.bugs_closed == "":
  220. return []
  221. bugs = re.split(r"[, ]+", self.bugs_closed)
  222. # First try a numeric sort, to show the best results for the majority
  223. # case of bug trackers with numeric IDs. If that fails, sort
  224. # alphabetically.
  225. try:
  226. bugs.sort(key=int)
  227. except ValueError:
  228. bugs.sort()
  229. return bugs
  230. def get_screenshots(self):
  231. """Returns the list of all screenshots on a review request.
  232. This includes all current screenshots, but not previous ones.
  233. By accessing screenshots through this method, future review request
  234. lookups from the screenshots will be avoided.
  235. """
  236. review_request = self._get_review_request()
  237. for screenshot in self.screenshots.all():
  238. screenshot._review_request = review_request
  239. yield screenshot
  240. def get_inactive_screenshots(self):
  241. """Returns the list of all inactive screenshots on a review request.
  242. This only includes screenshots that were previously visible but
  243. have since been removed.
  244. By accessing screenshots through this method, future review request
  245. lookups from the screenshots will be avoided.
  246. """
  247. review_request = self._get_review_request()
  248. for screenshot in self.inactive_screenshots.all():
  249. screenshot._review_request = review_request
  250. yield screenshot
  251. def get_file_attachments(self):
  252. """Returns the list of all file attachments on a review request.
  253. This includes all current file attachments, but not previous ones.
  254. By accessing file attachments through this method, future review
  255. request lookups from the file attachments will be avoided.
  256. """
  257. review_request = self._get_review_request()
  258. for file_attachment in self.file_attachments.all():
  259. file_attachment._review_request = review_request
  260. yield file_attachment
  261. def get_inactive_file_attachments(self):
  262. """Returns all inactive file attachments on a review request.
  263. This only includes file attachments that were previously visible
  264. but have since been removed.
  265. By accessing file attachments through this method, future review
  266. request lookups from the file attachments will be avoided.
  267. """
  268. review_request = self._get_review_request()
  269. for file_attachment in self.inactive_file_attachments.all():
  270. file_attachment._review_request = review_request
  271. yield file_attachment
  272. def add_default_reviewers(self):
  273. """Add default reviewers based on the diffset.
  274. This method goes through the DefaultReviewer objects in the database
  275. and adds any missing reviewers based on regular expression comparisons
  276. with the set of files in the diff.
  277. """
  278. diffset = self.get_latest_diffset()
  279. if not diffset:
  280. return
  281. people = set()
  282. groups = set()
  283. # TODO: This is kind of inefficient, and could maybe be optimized in
  284. # some fancy way. Certainly the most superficial optimization that
  285. # could be made would be to cache the compiled regexes somewhere.
  286. files = diffset.files.all()
  287. reviewers = DefaultReviewer.objects.for_repository(self.repository,
  288. self.local_site)
  289. for default in reviewers:
  290. try:
  291. regex = re.compile(default.file_regex)
  292. except:
  293. continue
  294. for filediff in files:
  295. if regex.match(filediff.source_file or filediff.dest_file):
  296. for person in default.people.all():
  297. people.add(person)
  298. for group in default.groups.all():
  299. groups.add(group)
  300. break
  301. existing_people = self.target_people.all()
  302. for person in people:
  303. if person not in existing_people:
  304. self.target_people.add(person)
  305. existing_groups = self.target_groups.all()
  306. for group in groups:
  307. if group not in existing_groups:
  308. self.target_groups.add(group)
  309. def update_from_commit_id(self, commit_id):
  310. """Updates the data from a server-side changeset.
  311. If the commit ID refers to a pending changeset on an SCM which stores
  312. such things server-side (like perforce), the details like the summary
  313. and description will be updated with the latest information.
  314. If the change number is the commit ID of a change which exists on the
  315. server, the summary and description will be set from the commit's
  316. message, and the diff will be fetched from the SCM."""
  317. scmtool = self.repository.get_scmtool()
  318. changeset = None
  319. if scmtool.supports_pending_changesets:
  320. changeset = scmtool.get_changeset(commit_id, allow_empty=True)
  321. if changeset and changeset.pending:
  322. self.update_from_pending_change(commit_id, changeset)
  323. elif self.repository.supports_post_commit:
  324. self.update_from_committed_change(commit_id)
  325. else:
  326. if changeset:
  327. raise InvalidChangeNumberError()
  328. else:
  329. raise NotImplementedError()
  330. def update_from_pending_change(self, commit_id, changeset):
  331. """Updates the data from a server-side pending changeset.
  332. This will fetch the metadata from the server and update the fields on
  333. the review request."""
  334. if not changeset:
  335. raise InvalidChangeNumberError()
  336. # If the SCM supports changesets, they should always include a number,
  337. # summary and description, parsed from the changeset description. Some
  338. # specialized systems may support the other fields, but we don't want
  339. # to clobber the user-entered values if they don't.
  340. if hasattr(self, 'changenum'):
  341. self.update_commit_id(commit_id)
  342. self.summary = changeset.summary
  343. self.description = changeset.description
  344. if changeset.testing_done:
  345. self.testing_done = changeset.testing_done
  346. if changeset.branch:
  347. self.branch = changeset.branch
  348. if changeset.bugs_closed:
  349. self.bugs_closed = ','.join(changeset.bugs_closed)
  350. def update_from_committed_change(self, commit_id):
  351. """Updates from a committed change present on the server.
  352. Fetches the commit message and diff from the repository and sets the
  353. relevant fields.
  354. """
  355. commit = self.repository.get_change(commit_id)
  356. summary, message = commit.split_message()
  357. if hasattr(self, 'commit_id'):
  358. self.commit = commit_id
  359. self.summary = summary.strip()
  360. self.description = message.strip()
  361. DiffSet.objects.create_from_data(
  362. repository=self.repository,
  363. diff_file_name='diff',
  364. diff_file_contents=commit.diff,
  365. parent_diff_file_name=None,
  366. parent_diff_file_contents=None,
  367. diffset_history=self.diffset_history,
  368. basedir='/',
  369. request=None)
  370. def save(self, **kwargs):
  371. self.bugs_closed = self.bugs_closed.strip()
  372. self.summary = self._truncate(self.summary, self.MAX_SUMMARY_LENGTH)
  373. super(BaseReviewRequestDetails, self).save(**kwargs)
  374. def _truncate(self, string, num):
  375. if len(string) > num:
  376. string = string[0:num]
  377. i = string.rfind('.')
  378. if i != -1:
  379. string = string[0:i + 1]
  380. return string
  381. def __unicode__(self):
  382. if self.summary:
  383. return self.summary
  384. else:
  385. return unicode(_('(no summary)'))
  386. class Meta:
  387. abstract = True
  388. class ReviewRequest(BaseReviewRequestDetails):
  389. """
  390. A review request.
  391. This is one of the primary models in Review Board. Most everything
  392. is associated with a review request.
  393. The ReviewRequest model contains detailed information on a review
  394. request. Some fields are user-modifiable, while some are used for
  395. internal state.
  396. """
  397. PENDING_REVIEW = "P"
  398. SUBMITTED = "S"
  399. DISCARDED = "D"
  400. STATUSES = (
  401. (PENDING_REVIEW, _('Pending Review')),
  402. (SUBMITTED, _('Submitted')),
  403. (DISCARDED, _('Discarded')),
  404. )
  405. submitter = models.ForeignKey(User, verbose_name=_("submitter"),
  406. related_name="review_requests")
  407. time_added = models.DateTimeField(_("time added"), default=timezone.now)
  408. last_updated = ModificationTimestampField(_("last updated"))
  409. status = models.CharField(_("status"), max_length=1, choices=STATUSES,
  410. db_index=True)
  411. public = models.BooleanField(_("public"), default=False)
  412. changenum = models.PositiveIntegerField(_("change number"), blank=True,
  413. null=True, db_index=True)
  414. commit_id = models.CharField(_('commit ID'), max_length=64, blank=True,
  415. null=True, db_index=True)
  416. repository = models.ForeignKey(Repository,
  417. related_name="review_requests",
  418. verbose_name=_("repository"),
  419. null=True,
  420. blank=True)
  421. email_message_id = models.CharField(_("e-mail message ID"), max_length=255,
  422. blank=True, null=True)
  423. time_emailed = models.DateTimeField(_("time e-mailed"), null=True,
  424. default=None, blank=True)
  425. diffset_history = models.ForeignKey(DiffSetHistory,
  426. related_name="review_request",
  427. verbose_name=_('diff set history'),
  428. blank=True)
  429. target_groups = models.ManyToManyField(
  430. Group,
  431. related_name="review_requests",
  432. verbose_name=_("target groups"),
  433. blank=True)
  434. target_people = models.ManyToManyField(
  435. User,
  436. verbose_name=_("target people"),
  437. related_name="directed_review_requests",
  438. blank=True)
  439. screenshots = models.ManyToManyField(
  440. Screenshot,
  441. related_name="review_request",
  442. verbose_name=_("screenshots"),
  443. blank=True)
  444. inactive_screenshots = models.ManyToManyField(
  445. Screenshot,
  446. verbose_name=_("inactive screenshots"),
  447. help_text=_("A list of screenshots that used to be but are no "
  448. "longer associated with this review request."),
  449. related_name="inactive_review_request",
  450. blank=True)
  451. file_attachments = models.ManyToManyField(
  452. FileAttachment,
  453. related_name="review_request",
  454. verbose_name=_("file attachments"),
  455. blank=True)
  456. inactive_file_attachments = models.ManyToManyField(
  457. FileAttachment,
  458. verbose_name=_("inactive file attachments"),
  459. help_text=_("A list of file attachments that used to be but are no "
  460. "longer associated with this review request."),
  461. related_name="inactive_review_request",
  462. blank=True)
  463. changedescs = models.ManyToManyField(
  464. ChangeDescription,
  465. verbose_name=_("change descriptions"),
  466. related_name="review_request",
  467. blank=True)
  468. depends_on = models.ManyToManyField('ReviewRequest',
  469. blank=True, null=True,
  470. verbose_name=_('Dependencies'),
  471. related_name='blocks')
  472. # Review-related information
  473. # The timestamp representing the last public activity of a review.
  474. # This includes publishing reviews and manipulating issues.
  475. last_review_activity_timestamp = models.DateTimeField(
  476. _("last review activity timestamp"),
  477. db_column='last_review_timestamp',
  478. null=True,
  479. default=None,
  480. blank=True)
  481. shipit_count = CounterField(_("ship-it count"), default=0)
  482. local_site = models.ForeignKey(LocalSite, blank=True, null=True)
  483. local_id = models.IntegerField('site-local ID', blank=True, null=True)
  484. # Set this up with the ReviewRequestManager
  485. objects = ReviewRequestManager()
  486. def get_commit(self):
  487. if self.commit_id is not None:
  488. return self.commit_id
  489. elif self.changenum is not None:
  490. self.commit_id = str(self.changenum)
  491. # Update the state in the database, but don't save this
  492. # model, or we can end up with some state (if we haven't
  493. # properly loaded everything yet). This affects docs.db
  494. # generation, and may cause problems in the wild.
  495. ReviewRequest.objects.filter(pk=self.pk).update(
  496. commit_id=str(self.changenum))
  497. return self.commit_id
  498. return None
  499. def set_commit(self, commit_id):
  500. try:
  501. self.changenum = int(commit_id)
  502. except (TypeError, ValueError):
  503. pass
  504. self.commit_id = commit_id
  505. commit = property(get_commit, set_commit)
  506. def get_participants(self):
  507. """
  508. Returns a list of all people who have been involved in discussing
  509. this review request.
  510. """
  511. # See the comment in Review.get_participants for this list
  512. # comprehension.
  513. return [u for review in self.reviews.all()
  514. for u in review.participants]
  515. participants = property(get_participants)
  516. def get_new_reviews(self, user):
  517. """
  518. Returns any new reviews since the user last viewed the review request.
  519. """
  520. if user.is_authenticated():
  521. # If this ReviewRequest was queried using with_counts=True,
  522. # then we should know the new review count and can use this to
  523. # decide whether we have anything at all to show.
  524. if hasattr(self, "new_review_count") and self.new_review_count > 0:
  525. query = self.visits.filter(user=user)
  526. try:
  527. visit = query[0]
  528. return self.reviews.filter(
  529. public=True,
  530. timestamp__gt=visit.timestamp).exclude(user=user)
  531. except IndexError:
  532. # This visit doesn't exist, so bail.
  533. pass
  534. return self.reviews.get_empty_query_set()
  535. def get_display_id(self):
  536. """Gets the ID which should be exposed to the user."""
  537. if self.local_site_id:
  538. return self.local_id
  539. else:
  540. return self.id
  541. display_id = property(get_display_id)
  542. def get_public_reviews(self):
  543. """
  544. Returns all public top-level reviews for this review request.
  545. """
  546. return self.reviews.filter(public=True, base_reply_to__isnull=True)
  547. def is_accessible_by(self, user, local_site=None):
  548. """Returns whether or not the user can read this review request.
  549. This performs several checks to ensure that the user has access.
  550. This user has access if:
  551. * The review request is public or the user can modify it (either
  552. by being an owner or having special permissions).
  553. * The repository is public or the user has access to it (either by
  554. being explicitly on the allowed users list, or by being a member
  555. of a review group on that list).
  556. * The user is listed as a requested reviewer or the user has access
  557. to one or more groups listed as requested reviewers (either by
  558. being a member of an invite-only group, or the group being public).
  559. """
  560. # Users always have access to their own review requests.
  561. if self.submitter == user:
  562. return True
  563. if not self.public and not self.is_mutable_by(user):
  564. return False
  565. if self.repository and not self.repository.is_accessible_by(user):
  566. return False
  567. if local_site and not local_site.is_accessible_by(user):
  568. return False
  569. if (user.is_authenticated() and
  570. self.target_people.filter(pk=user.pk).count() > 0):
  571. return True
  572. groups = list(self.target_groups.all())
  573. if not groups:
  574. return True
  575. # We specifically iterate over these instead of making it part
  576. # of the query in order to keep the logic in Group, and to allow
  577. # for future expansion (extensions, more advanced policy)
  578. #
  579. # We're looking for at least one group that the user has access
  580. # to. If they can access any of the groups, then they have access
  581. # to the review request.
  582. for group in groups:
  583. if group.is_accessible_by(user):
  584. return True
  585. return False
  586. def is_mutable_by(self, user):
  587. "Returns true if the user can modify this review request"
  588. return (self.submitter == user or
  589. user.has_perm('reviews.can_edit_reviewrequest'))
  590. def get_draft(self, user=None):
  591. """
  592. Returns the draft of the review request. If a user is specified,
  593. than the draft will be returned only if owned by the user. Otherwise,
  594. None will be returned.
  595. """
  596. if not user:
  597. return get_object_or_none(self.draft)
  598. elif user.is_authenticated():
  599. return get_object_or_none(self.draft,
  600. review_request__submitter=user)
  601. return None
  602. def get_pending_review(self, user):
  603. """
  604. Returns the pending review owned by the specified user, if any.
  605. This will return an actual review, not a reply to a review.
  606. """
  607. return Review.objects.get_pending_review(self, user)
  608. def get_last_activity(self, diffsets=None, reviews=None):
  609. """Returns the last public activity information on the review request.
  610. This will return the last object updated, along with the timestamp
  611. of that object. It can be used to judge whether something on a
  612. review request has been made public more recently.
  613. """
  614. timestamp = self.last_updated
  615. updated_object = self
  616. # Check if the diff was updated along with this.
  617. if not diffsets and self.repository_id:
  618. latest_diffset = self.get_latest_diffset()
  619. diffsets = []
  620. if latest_diffset:
  621. diffsets.append(latest_diffset)
  622. if diffsets:
  623. for diffset in diffsets:
  624. if diffset.timestamp >= timestamp:
  625. timestamp = diffset.timestamp
  626. updated_object = diffset
  627. # Check for the latest review or reply.
  628. if not reviews:
  629. try:
  630. reviews = [self.reviews.filter(public=True).latest()]
  631. except Review.DoesNotExist:
  632. reviews = []
  633. for review in reviews:
  634. if review.public and review.timestamp >= timestamp:
  635. timestamp = review.timestamp
  636. updated_object = review
  637. return timestamp, updated_object
  638. def changeset_is_pending(self):
  639. """
  640. Returns True if the current changeset associated with this review
  641. request is pending under SCM.
  642. """
  643. changeset = None
  644. commit_id = self.commit
  645. if (self.repository.get_scmtool().supports_pending_changesets and
  646. commit_id is not None):
  647. changeset = self.repository.get_scmtool().get_changeset(
  648. commit_id, allow_empty=True)
  649. return changeset and changeset.pending
  650. def get_absolute_url(self):
  651. if self.local_site:
  652. local_site_name = self.local_site.name
  653. else:
  654. local_site_name = None
  655. return local_site_reverse(
  656. 'review-request-detail',
  657. local_site_name=local_site_name,
  658. kwargs={'review_request_id': self.display_id})
  659. def get_diffsets(self):
  660. """Returns a list of all diffsets on this review request."""
  661. if not self.repository_id:
  662. return []
  663. if not hasattr(self, '_diffsets'):
  664. self._diffsets = list(DiffSet.objects.filter(
  665. history__pk=self.diffset_history_id))
  666. return self._diffsets
  667. def get_latest_diffset(self):
  668. """Returns the latest diffset for this review request."""
  669. try:
  670. return DiffSet.objects.filter(
  671. history=self.diffset_history_id).latest()
  672. except DiffSet.DoesNotExist:
  673. return None
  674. def save(self, update_counts=False, **kwargs):
  675. if update_counts or self.id is None:
  676. self._update_counts()
  677. if self.status != self.PENDING_REVIEW:
  678. # If this is not a pending review request now, delete any
  679. # and all ReviewRequestVisit objects.
  680. self.visits.all().delete()
  681. super(ReviewRequest, self).save(**kwargs)
  682. def delete(self, **kwargs):
  683. from reviewboard.accounts.models import Profile, LocalSiteProfile
  684. profile, profile_is_new = \
  685. Profile.objects.get_or_create(user=self.submitter)
  686. if profile_is_new:
  687. profile.save()
  688. local_site = self.local_site
  689. site_profile, site_profile_is_new = \
  690. LocalSiteProfile.objects.get_or_create(user=self.submitter,
  691. profile=profile,
  692. local_site=local_site)
  693. site_profile.decrement_total_outgoing_request_count()
  694. if self.status == self.PENDING_REVIEW:
  695. site_profile.decrement_pending_outgoing_request_count()
  696. if self.public:
  697. people = self.target_people.all()
  698. groups = self.target_groups.all()
  699. Group.incoming_request_count.decrement(groups)
  700. LocalSiteProfile.direct_incoming_request_count.decrement(
  701. LocalSiteProfile.objects.filter(user__in=people,
  702. local_site=local_site))
  703. LocalSiteProfile.total_incoming_request_count.decrement(
  704. LocalSiteProfile.objects.filter(
  705. Q(local_site=local_site) &
  706. Q(Q(user__review_groups__in=groups) |
  707. Q(user__in=people))))
  708. LocalSiteProfile.starred_public_request_count.decrement(
  709. LocalSiteProfile.objects.filter(
  710. profile__starred_review_requests=self,
  711. local_site=local_site))
  712. super(ReviewRequest, self).delete(**kwargs)
  713. def can_publish(self):
  714. return not self.public or get_object_or_none(self.draft) is not None
  715. def close(self, type, user=None, description=None):
  716. """
  717. Closes the review request. The type must be one of
  718. SUBMITTED or DISCARDED.
  719. """
  720. if (user and not self.is_mutable_by(user) and
  721. not user.has_perm("reviews.can_change_status")):
  722. raise PermissionError
  723. if type not in [self.SUBMITTED, self.DISCARDED]:
  724. raise AttributeError("%s is not a valid close type" % type)
  725. if self.status != type:
  726. changedesc = ChangeDescription(public=True, text=description or "")
  727. changedesc.record_field_change('status', self.status, type)
  728. changedesc.save()
  729. self.changedescs.add(changedesc)
  730. self.status = type
  731. self.save(update_counts=True)
  732. review_request_closed.send(sender=self.__class__, user=user,
  733. review_request=self,
  734. type=type)
  735. else:
  736. # Update submission description.
  737. changedesc = self.changedescs.filter(public=True).latest()
  738. changedesc.timestamp = timezone.now()
  739. changedesc.text = description or ""
  740. changedesc.save()
  741. # Needed to renew last-update.
  742. self.save()
  743. try:
  744. draft = self.draft.get()
  745. except ReviewRequestDraft.DoesNotExist:
  746. pass
  747. else:
  748. draft.delete()
  749. def reopen(self, user=None):
  750. """
  751. Reopens the review request for review.
  752. """
  753. if (user and not self.is_mutable_by(user) and
  754. not user.has_perm("reviews.can_change_status")):
  755. raise PermissionError
  756. if self.status != self.PENDING_REVIEW:
  757. changedesc = ChangeDescription()
  758. changedesc.record_field_change('status', self.status,
  759. self.PENDING_REVIEW)
  760. if self.status == self.DISCARDED:
  761. # A draft is needed if reopening a discarded review request.
  762. self.public = False
  763. changedesc.save()
  764. draft = ReviewRequestDraft.create(self)
  765. draft.changedesc = changedesc
  766. draft.save()
  767. else:
  768. changedesc.public = True
  769. changedesc.save()
  770. self.changedescs.add(changedesc)
  771. self.status = self.PENDING_REVIEW
  772. self.save(update_counts=True)
  773. review_request_reopened.send(sender=self.__class__, user=user,
  774. review_request=self)
  775. def update_commit_id(self, commit_id, user=None):
  776. if (user and not self.is_mutable_by(user)):
  777. raise PermissionError
  778. self.commit = commit_id
  779. def publish(self, user):
  780. """
  781. Save the current draft attached to this review request. Send out the
  782. associated email. Returns the review request that was saved.
  783. """
  784. from reviewboard.accounts.models import LocalSiteProfile
  785. if not self.is_mutable_by(user):
  786. raise PermissionError
  787. # Decrement the counts on everything. we lose them.
  788. # We'll increment the resulting set during ReviewRequest.save.
  789. # This should be done before the draft is published.
  790. # Once the draft is published, the target people
  791. # and groups will be updated with new values.
  792. # Decrement should not happen while publishing
  793. # a new request or a discarded request
  794. if self.public:
  795. Group.incoming_request_count.decrement(self.target_groups.all())
  796. LocalSiteProfile.direct_incoming_request_count.decrement(
  797. LocalSiteProfile.objects.filter(
  798. user__in=self.target_people.all(),
  799. local_site=self.local_site))
  800. LocalSiteProfile.total_incoming_request_count.decrement(
  801. LocalSiteProfile.objects.filter(
  802. Q(local_site=self.local_site) &
  803. Q(Q(user__review_groups__in=self.target_groups.all()) |
  804. Q(user__in=self.target_people.all()))))
  805. LocalSiteProfile.starred_public_request_count.decrement(
  806. LocalSiteProfile.objects.filter(
  807. profile__starred_review_requests=self,
  808. local_site=self.local_site))
  809. draft = get_object_or_none(self.draft)
  810. if draft is not None:
  811. # This will in turn save the review request, so we'll be done.
  812. changes = draft.publish(self, send_notification=False)
  813. draft.delete()
  814. else:
  815. changes = None
  816. if not self.public and self.changedescs.count() == 0:
  817. # This is a brand new review request that we're publishing
  818. # for the first time. Set the creation timestamp to now.
  819. self.time_added = timezone.now()
  820. self.public = True
  821. self.save(update_counts=True)
  822. review_request_published.send(sender=self.__class__, user=user,
  823. review_request=self,
  824. changedesc=changes)
  825. def _update_counts(self):
  826. from reviewboard.accounts.models import Profile, LocalSiteProfile
  827. profile, profile_is_new = \
  828. Profile.objects.get_or_create(user=self.submitter)
  829. if profile_is_new:
  830. profile.save()
  831. local_site = self.local_site
  832. site_profile, site_profile_is_new = \
  833. LocalSiteProfile.objects.get_or_create(
  834. user=self.submitter,
  835. profile=profile,
  836. local_site=local_site)
  837. if site_profile_is_new:
  838. site_profile.save()
  839. if self.id is None:
  840. # This hasn't been created yet. Bump up the outgoing request
  841. # count for the user.
  842. site_profile.increment_total_outgoing_request_count()
  843. old_status = None
  844. old_public = False
  845. else:
  846. # We need to see if the status has changed, so that means
  847. # finding out what's in the database.
  848. r = ReviewRequest.objects.get(pk=self.id)
  849. old_status = r.status
  850. old_public = r.public
  851. if self.status == self.PENDING_REVIEW:
  852. if old_status != self.status:
  853. site_profile.increment_pending_outgoing_request_count()
  854. if self.public and self.id is not None:
  855. groups = self.target_groups.all()
  856. people = self.target_people.all()
  857. Group.incoming_request_count.increment(groups)
  858. LocalSiteProfile.direct_incoming_request_count.increment(
  859. LocalSiteProfile.objects.filter(user__in=people,
  860. local_site=local_site))
  861. LocalSiteProfile.total_incoming_request_count.increment(
  862. LocalSiteProfile.objects.filter(
  863. Q(local_site=local_site) &
  864. Q(Q(user__review_groups__in=groups) |
  865. Q(user__in=people))))
  866. LocalSiteProfile.starred_public_request_count.increment(
  867. LocalSiteProfile.objects.filter(
  868. profile__starred_review_requests=self,
  869. local_site=local_site))
  870. else:
  871. if old_status != self.status:
  872. site_profile.decrement_pending_outgoing_request_count()
  873. if old_public:
  874. groups = self.target_groups.all()
  875. people = self.target_people.all()
  876. Group.incoming_request_count.decrement(groups)
  877. LocalSiteProfile.direct_incoming_request_count.decrement(
  878. LocalSiteProfile.objects.filter(user__in=people,
  879. local_site=local_site))
  880. LocalSiteProfile.total_incoming_request_count.decrement(
  881. LocalSiteProfile.objects.filter(
  882. Q(local_site=local_site) &
  883. Q(Q(user__review_groups__in=groups) |
  884. Q(user__in=people))))
  885. LocalSiteProfile.starred_public_request_count.decrement(
  886. LocalSiteProfile.objects.filter(
  887. profile__starred_review_requests=self,
  888. local_site=local_site))
  889. def _get_review_request(self):
  890. """Returns this review request.
  891. This is an interface needed by ReviewRequestDetails.
  892. """
  893. return self
  894. class Meta:
  895. ordering = ['-last_updated', 'submitter', 'summary']
  896. unique_together = (('commit_id', 'repository'),
  897. ('changenum', 'repository'),
  898. ('local_site', 'local_id'))
  899. permissions = (
  900. ("can_change_status", "Can change status"),
  901. ("can_submit_as_another_user", "Can submit as another user"),
  902. ("can_edit_reviewrequest", "Can edit review request"),
  903. )
  904. class ReviewRequestDraft(BaseReviewRequestDetails):
  905. """
  906. A draft of a review request.
  907. When a review request is being modified, a special draft copy of it is
  908. created containing all the details of the review request. This copy can
  909. be modified and eventually saved or discarded. When saved, the new
  910. details are copied back over to the originating ReviewRequest.
  911. """
  912. review_request = models.ForeignKey(ReviewRequest,
  913. related_name="draft",
  914. verbose_name=_("review request"),
  915. unique=True)
  916. last_updated = ModificationTimestampField(_("last updated"))
  917. diffset = models.ForeignKey(DiffSet, verbose_name=_('diff set'),
  918. blank=True, null=True,
  919. related_name='review_request_draft')
  920. changedesc = models.ForeignKey(ChangeDescription,
  921. verbose_name=_('change description'),
  922. blank=True, null=True)
  923. target_groups = models.ManyToManyField(Group,
  924. related_name="drafts",
  925. verbose_name=_("target groups"),
  926. blank=True)
  927. target_people = models.ManyToManyField(User,
  928. verbose_name=_("target people"),
  929. related_name="directed_drafts",
  930. blank=True)
  931. screenshots = models.ManyToManyField(Screenshot,
  932. related_name="drafts",
  933. verbose_name=_("screenshots"),
  934. blank=True)
  935. inactive_screenshots = models.ManyToManyField(
  936. Screenshot,
  937. verbose_name=_("inactive screenshots"),
  938. related_name="inactive_drafts",
  939. blank=True)
  940. file_attachments = models.ManyToManyField(
  941. FileAttachment,
  942. related_name="drafts",
  943. verbose_name=_("file attachments"),
  944. blank=True)
  945. inactive_file_attachments = models.ManyToManyField(
  946. FileAttachment,
  947. verbose_name=_("inactive files"),
  948. related_name="inactive_drafts",
  949. blank=True)
  950. submitter = property(lambda self: self.review_request.submitter)
  951. repository = property(lambda self: self.review_request.repository)
  952. local_site = property(lambda self: self.review_request.local_site)
  953. depends_on = models.ManyToManyField('ReviewRequest',
  954. blank=True, null=True,
  955. verbose_name=_('Dependencies'),
  956. related_name='draft_blocks')
  957. # Set this up with a ConcurrencyManager to help prevent race conditions.
  958. objects = ConcurrencyManager()
  959. def get_latest_diffset(self):
  960. """Returns the diffset for this draft."""
  961. return self.diffset
  962. def is_accessible_by(self, user):
  963. """Returns whether or not the user can access this draft."""
  964. return self.is_mutable_by(user)
  965. def is_mutable_by(self, user):
  966. """Returns whether or not the user can modify this draft."""
  967. return self.review_request.is_mutable_by(user)
  968. @staticmethod
  969. def create(review_request):
  970. """
  971. Creates a draft based on a review request.
  972. This will copy over all the details of the review request that
  973. we care about. If a draft already exists for the review request,
  974. the draft will be returned.
  975. """
  976. draft, draft_is_new = \
  977. ReviewRequestDraft.objects.get_or_create(
  978. review_request=review_request,
  979. defaults={
  980. 'summary': review_request.summary,
  981. 'description': review_request.description,
  982. 'testing_done': review_request.testing_done,
  983. 'bugs_closed': review_request.bugs_closed,
  984. 'branch': review_request.branch,
  985. })
  986. if draft.changedesc is None and review_request.public:
  987. changedesc = ChangeDescription()
  988. changedesc.save()
  989. draft.changedesc = changedesc
  990. if draft_is_new:
  991. map(draft.target_groups.add, review_request.target_groups.all())
  992. map(draft.target_people.add, review_request.target_people.all())
  993. for screenshot in review_request.screenshots.all():
  994. screenshot.draft_caption = screenshot.caption
  995. screenshot.save()
  996. draft.screenshots.add(screenshot)
  997. for screenshot in review_request.inactive_screenshots.all():
  998. screenshot.draft_caption = screenshot.caption
  999. screenshot.save()
  1000. draft.inactive_screenshots.add(screenshot)
  1001. for attachment in review_request.file_attachments.all():
  1002. attachment.draft_caption = attachment.caption
  1003. attachment.save()
  1004. draft.file_attachments.add(attachment)
  1005. for attachment in review_request.inactive_file_attachments.all():
  1006. attachment.draft_caption = attachment.caption
  1007. attachment.save()
  1008. draft.inactive_file_attachments.add(attachment)
  1009. draft.save()
  1010. return draft
  1011. def publish(self, review_request=None, user=None,
  1012. send_notification=True):
  1013. """
  1014. Publishes this draft. Uses the draft's assocated ReviewRequest
  1015. object if one isn't passed in.
  1016. This updates and returns the draft's ChangeDescription, which
  1017. contains the changed fields. This is used by the e-mail template
  1018. to tell people what's new and interesting.
  1019. The keys that may be saved in 'fields_changed' in the
  1020. ChangeDescription are:
  1021. * 'summary'
  1022. * 'description'
  1023. * 'testing_done'
  1024. * 'bugs_closed'
  1025. * 'depends_on'
  1026. * 'branch'
  1027. * 'target_groups'
  1028. * 'target_people'
  1029. * 'screenshots'
  1030. * 'screenshot_captions'
  1031. * 'diff'
  1032. Each field in 'fields_changed' represents a changed field. This will
  1033. save fields in the standard formats as defined by the
  1034. 'ChangeDescription' documentation, with the exception of the
  1035. 'screenshot_captions' and 'diff' fields.
  1036. For the 'screenshot_captions' field, the value will be a dictionary
  1037. of screenshot ID/dict pairs with the following fields:
  1038. * 'old': The old value of the field
  1039. * 'new': The new value of the field
  1040. For the 'diff' field, there is only ever an 'added' field, containing
  1041. the ID of the new diffset.
  1042. The 'send_notification' parameter is intended for internal use only,
  1043. and is there to prevent duplicate notifications when being called by
  1044. ReviewRequest.publish.
  1045. """
  1046. if not review_request:
  1047. review_request = self.review_request
  1048. if not user:
  1049. user = review_request.submitter
  1050. if not self.changedesc and review_request.public:
  1051. self.changedesc = ChangeDescription()
  1052. def update_field(a, b, name, record_changes=True):
  1053. # Apparently django models don't have __getattr__ or __setattr__,
  1054. # so we have to update __dict__ directly. Sigh.
  1055. value = b.__dict__[name]
  1056. old_value = a.__dict__[name]
  1057. if old_value != value:
  1058. if record_changes and self.changedesc:
  1059. self.changedesc.record_field_change(name, old_value, value)
  1060. a.__dict__[name] = value
  1061. def update_list(a, b, name, record_changes=True, name_field=None):
  1062. aset = set([x.id for x in a.all()])
  1063. bset = set([x.id for x in b.all()])
  1064. if aset.symmetric_difference(bset):
  1065. if record_changes and self.changedesc:
  1066. self.changedesc.record_field_change(name, a.all(), b.all(),
  1067. name_field)
  1068. a.clear()
  1069. map(a.add, b.all())
  1070. update_field(review_request, self, 'summary')
  1071. update_field(review_request, self, 'description')
  1072. update_field(review_request, self, 'testing_done')
  1073. update_field(review_request, self, 'branch')
  1074. update_list(review_request.target_groups, self.target_groups,
  1075. 'target_groups', name_field="name")
  1076. update_list(review_request.target_people, self.target_people,
  1077. 'target_people', name_field="username")
  1078. update_list(review_request.depends_on, self.depends_on,
  1079. 'depends_on', name_field='summary')
  1080. # Specifically handle bug numbers
  1081. old_bugs = review_request.get_bug_list()
  1082. new_bugs = self.get_bug_list()
  1083. if set(old_bugs) != set(new_bugs):
  1084. update_field(review_request, self, 'bugs_closed',
  1085. record_changes=False)
  1086. if self.changedesc:
  1087. self.changedesc.record_field_change('bugs_closed',
  1088. old_bugs, new_bugs)
  1089. # Screenshots are a bit special. The list of associated screenshots
  1090. # can change, but so can captions within each screenshot.
  1091. screenshots = self.screenshots.all()
  1092. caption_changes = {}
  1093. for s in review_request.screenshots.all():
  1094. if s in screenshots and s.caption != s.draft_caption:
  1095. caption_changes[s.id] = {
  1096. 'old': (s.caption,),
  1097. 'new': (s.draft_caption,),
  1098. }
  1099. s.caption = s.draft_caption
  1100. s.save()
  1101. # Now scan through again and set the caption correctly for newly-added
  1102. # screenshots by copying the draft_caption over. We don't need to
  1103. # include this in the changedescs here because it's a new screenshot,
  1104. # and update_list will record the newly-added item.
  1105. for s in screenshots:
  1106. if s.caption != s.draft_caption:
  1107. s.caption = s.draft_caption
  1108. s.save()
  1109. if caption_changes and self.changedesc:
  1110. self.changedesc.fields_changed['screenshot_captions'] = \
  1111. caption_changes
  1112. update_list(review_request.screenshots, self.screenshots,
  1113. 'screenshots', name_field="caption")
  1114. # There's no change notification required for this field.
  1115. review_request.inactive_screenshots.clear()
  1116. map(review_request.inactive_screenshots.add,
  1117. self.inactive_screenshots.all())
  1118. # Files are treated like screenshots. The list of files can
  1119. # change, but so can captions within each file.
  1120. files = self.file_attachments.all()
  1121. caption_changes = {}
  1122. for f in review_request.file_attachments.all():
  1123. if f in files and f.caption != f.draft_caption:
  1124. caption_changes[f.id] = {
  1125. 'old': (f.caption,),
  1126. 'new': (f.draft_caption,),
  1127. }
  1128. f.caption = f.draft_caption
  1129. f.save()
  1130. # Now scan through again and set the caption correctly for newly-added
  1131. # files by copying the draft_caption over. We don't need to include
  1132. # this in the changedescs here because it's a new screenshot, and
  1133. # update_list will record the newly-added item.
  1134. for f in files:
  1135. if f.caption != f.draft_caption:
  1136. f.caption = f.draft_caption
  1137. f.save()
  1138. if caption_changes and self.changedesc:
  1139. self.changedesc.fields_changed['file_captions'] = \
  1140. caption_changes
  1141. update_list(review_request.file_attachments, self.file_attachments,
  1142. 'files', name_field="display_name")
  1143. # There's no change notification required for this field.
  1144. review_request.inactive_file_attachments.clear()
  1145. map(review_request.inactive_file_attachments.add,
  1146. self.inactive_file_attachments.all())
  1147. if self.diffset:
  1148. if self.changedesc:
  1149. if review_request.local_site:
  1150. local_site_name = review_request.local_site.name
  1151. else:
  1152. local_site_name = None
  1153. url = local_site_reverse(
  1154. 'view_diff_revision',
  1155. local_site_name=local_site_name,
  1156. args=[review_request.display_id, self.diffset.revision])
  1157. self.changedesc.fields_changed['diff'] = {
  1158. 'added': [(_("Diff r%s") % self.diffset.revision,
  1159. url,
  1160. self.diffset.id)],
  1161. }
  1162. self.diffset.history = review_request.diffset_history
  1163. self.diffset.save()
  1164. if self.changedesc:
  1165. self.changedesc.timestamp = timezone.now()
  1166. self.changedesc.public = True
  1167. self.changedesc.save()
  1168. review_request.changedescs.add(self.changedesc)
  1169. review_request.save()
  1170. if send_notification:
  1171. review_request_published.send(sender=review_request.__class__,
  1172. user=user,
  1173. review_request=review_request,
  1174. changedesc=self.changedesc)
  1175. return self.changedesc
  1176. def _get_review_request(self):
  1177. """Returns the associated review request.
  1178. This is an interface needed by ReviewRequestDetails.
  1179. """
  1180. return self.review_request
  1181. class Meta:
  1182. ordering = ['-last_updated']
  1183. class BaseComment(models.Model):
  1184. OPEN = "O"
  1185. RESOLVED = "R"
  1186. DROPPED = "D"
  1187. ISSUE_STATUSES = (
  1188. (OPEN, _('Open')),
  1189. (RESOLVED, _('Resolved')),
  1190. (DROPPED, _('Dropped')),
  1191. )
  1192. issue_opened = models.BooleanField(_("issue opened"), default=False)
  1193. issue_status = models.CharField(_("issue status"),
  1194. max_length=1,
  1195. choices=ISSUE_STATUSES,
  1196. blank=True,
  1197. null=True,
  1198. db_index=True)
  1199. reply_to = models.ForeignKey("self", blank=True, null=True,
  1200. related_name="replies",
  1201. verbose_name=_("reply to"))
  1202. timestamp = models.DateTimeField(_('timestamp'), default=timezone.now)
  1203. text = models.TextField(_("comment text"))
  1204. rich_text = models.BooleanField(_("rich text"), default=True)
  1205. extra_data = JSONField(null=True)
  1206. # Set this up with a ConcurrencyManager to help prevent race conditions.
  1207. objects = ConcurrencyManager()
  1208. @staticmethod
  1209. def issue_status_to_string(status):
  1210. if status == "O":
  1211. return "open"
  1212. elif status == "R":
  1213. return "resolved"
  1214. elif status == "D":
  1215. return "dropped"
  1216. else:
  1217. return ""
  1218. @staticmethod
  1219. def issue_string_to_status(status):
  1220. if status == "open":
  1221. return "O"
  1222. elif status == "resolved":
  1223. return "R"
  1224. elif status == "dropped":
  1225. return "D"
  1226. else:
  1227. raise Exception("Invalid issue status '%s'" % status)
  1228. def get_review_request(self):
  1229. if hasattr(self, '_review_request'):
  1230. return self._review_request
  1231. else:
  1232. return self.get_review().review_request
  1233. def get_review(self):
  1234. if hasattr(self, '_review'):
  1235. return self._review
  1236. else:
  1237. return self.review.get()
  1238. def get_review_url(self):
  1239. return "%s#%s%d" % \
  1240. (self.get_review_request().get_absolute_url(),
  1241. self.anchor_prefix, self.id)
  1242. def is_reply(self):
  1243. """Returns whether this comment is a reply to another comment."""
  1244. return self.reply_to_id is not None
  1245. is_reply.boolean = True
  1246. def is_accessible_by(self, user):
  1247. """Returns whether the user can access this comment."""
  1248. return self.get_review().is_accessible_by(user)
  1249. def is_mutable_by(self, user):
  1250. """Returns whether the user can modify this comment."""
  1251. return self.get_review().is_mutable_by(user)
  1252. def public_replies(self, user=None):
  1253. """
  1254. Returns a list of public replies to this comment, optionally
  1255. specifying the user replying.
  1256. """
  1257. if hasattr(self, '_replies'):
  1258. return self._replies
  1259. if user:
  1260. return self.replies.filter(Q(review__public=True) |
  1261. Q(review__user=user))
  1262. else:
  1263. return self.replies.filter(review__public=True)
  1264. def can_change_issue_status(self, user):
  1265. """Returns whether the user can change the issue status.
  1266. Currently, this is allowed for:
  1267. - The user who owns the review request.
  1268. - The user who opened the issue (posted the comment).
  1269. """
  1270. if not (user and user.is_authenticated()):
  1271. return False
  1272. return (self.get_review_request().is_mutable_by(user) or
  1273. user == self.get_review().user)
  1274. def save(self, **kwargs):
  1275. self.timestamp = timezone.now()
  1276. super(BaseComment, self).save()
  1277. try:
  1278. # Update the review timestamp, but only if it's a draft.
  1279. # Otherwise, resolving an issue will change the timestamp of
  1280. # the review.
  1281. review = self.get_review()
  1282. if not review.public:
  1283. review.timestamp = self.timestamp
  1284. review.save()
  1285. ReviewRequest.objects.filter(pk=review.review_request_id).update(
  1286. last_review_activity_timestamp=self.timestamp)
  1287. except Review.DoesNotExist:
  1288. pass
  1289. def __unicode__(self):
  1290. return self.text
  1291. class Meta:
  1292. abstract = True
  1293. ordering = ['timestamp']
  1294. class Comment(BaseComment):
  1295. """
  1296. A comment made on a diff.
  1297. A comment can belong to a single filediff or to an interdiff between
  1298. two filediffs. It can also have multiple replies.
  1299. """
  1300. anchor_prefix = "comment"
  1301. comment_type = "diff"
  1302. filediff = models.ForeignKey(FileDiff, verbose_name=_('file diff'),
  1303. related_name="comments")
  1304. interfilediff = models.ForeignKey(FileDiff,
  1305. verbose_name=_('interdiff file'),
  1306. blank=True, null=True,
  1307. related_name="interdiff_comments")
  1308. # A null line number applies to an entire diff. Non-null line numbers are
  1309. # the line within the entire file, starting at 1.
  1310. first_line = models.PositiveIntegerField(_("first line"), blank=True,
  1311. null=True)
  1312. num_lines = models.PositiveIntegerField(_("number of lines"), blank=True,
  1313. null=True)
  1314. last_line = property(lambda self: self.first_line + self.num_lines - 1)
  1315. def get_absolute_url(self):
  1316. revision_path = str(self.filediff.diffset.revision)
  1317. if self.interfilediff:
  1318. revision_path += "-%s" % self.interfilediff.diffset.revision
  1319. return "%sdiff/%s/?file=%s#file%sline%s" % (
  1320. self.get_review_request().get_absolute_url(),
  1321. revision_path, self.filediff.id, self.filediff.id,
  1322. self.first_line)
  1323. class ScreenshotComment(BaseComment):
  1324. """
  1325. A comment on a screenshot.
  1326. """
  1327. anchor_prefix = "scomment"
  1328. comment_type = "screenshot"
  1329. screenshot = models.ForeignKey(Screenshot, verbose_name=_('screenshot'),
  1330. related_name="comments")
  1331. # This is a sub-region of the screenshot. Null X indicates the entire
  1332. # image.
  1333. x = models.PositiveSmallIntegerField(_("sub-image X"), null=True)
  1334. y = models.PositiveSmallIntegerField(_("sub-image Y"))
  1335. w = models.PositiveSmallIntegerField(_("sub-image width"))
  1336. h = models.PositiveSmallIntegerField(_("sub-image height"))
  1337. def get_image_url(self):
  1338. """
  1339. Returns the URL for the thumbnail, creating it if necessary.
  1340. """
  1341. return crop_image(self.screenshot.image, self.x, self.y,
  1342. self.w, self.h)
  1343. def image(self):
  1344. """
  1345. Generates the cropped part of the screenshot referenced by this
  1346. comment and returns the HTML markup embedding it.
  1347. """
  1348. return '<img src="%s" width="%s" height="%s" alt="%s" />' % \
  1349. (self.get_image_url(), self.w, self.h, escape(self.text))
  1350. class FileAttachmentComment(BaseComment):
  1351. """A comment on a file attachment."""
  1352. anchor_prefix = "fcomment"
  1353. comment_type = "file"
  1354. file_attachment = models.ForeignKey(
  1355. FileAttachment,
  1356. verbose_name=_('file attachment'),
  1357. related_name="comments")
  1358. diff_against_file_attachment = models.ForeignKey(
  1359. FileAttachment,
  1360. verbose_name=_('diff against file attachment'),
  1361. related_name="diffed_against_comments",
  1362. null=True)
  1363. @property
  1364. def thumbnail(self):
  1365. """Returns the thumbnail for this comment, if any, as HTML.
  1366. The thumbnail will be generated from the appropriate ReviewUI,
  1367. if there is one for this type of file.
  1368. """
  1369. return self.file_attachment.review_ui.get_comment_thumbnail(self)
  1370. def get_absolute_url(self):
  1371. """Returns the URL for this comment."""
  1372. if self.file_attachment.review_ui:
  1373. return self.file_attachment.review_ui.get_comment_link_url(self)
  1374. else:
  1375. return self.file_attachment.get_absolute_url()
  1376. def get_link_text(self):
  1377. """Returns the text for the link to the file."""
  1378. if self.file_attachment.review_ui:
  1379. return self.file_attachment.review_ui.get_comment_link_text(self)
  1380. else:
  1381. return self.file_attachment.filename
  1382. def get_file(self):
  1383. """
  1384. Generates the file referenced by this
  1385. comment and returns the HTML markup embedding it.
  1386. """
  1387. return '<a href="%s" alt="%s" />' % (self.file_attachment.file,
  1388. escape(self.text))
  1389. class Review(models.Model):
  1390. """
  1391. A review of a review request.
  1392. """
  1393. review_request = models.ForeignKey(ReviewRequest,
  1394. related_name="reviews",
  1395. verbose_name=_("review request"))
  1396. user = models.ForeignKey(User, verbose_name=_("user"),
  1397. related_name="reviews")
  1398. timestamp = models.DateTimeField(_('timestamp'), default=timezone.now)
  1399. public = models.BooleanField(_("public"), default=False)
  1400. ship_it = models.BooleanField(
  1401. _("ship it"),
  1402. default=False,
  1403. help_text=_("Indicates whether the reviewer thinks this code is "
  1404. "ready to ship."))
  1405. base_reply_to = models.ForeignKey(
  1406. "self",
  1407. blank=True,
  1408. null=True,
  1409. related_name="replies",
  1410. verbose_name=_("Base reply to"),
  1411. help_text=_("The top-most review in the discussion thread for "
  1412. "this review reply."))
  1413. email_message_id = models.CharField(_("e-mail message ID"), max_length=255,
  1414. blank=True, null=True)
  1415. time_emailed = models.DateTimeField(_("time e-mailed"), null=True,
  1416. default=None, blank=True)
  1417. body_top = models.TextField(
  1418. _("body (top)"),
  1419. blank=True,
  1420. help_text=_("The review text shown above the diff and screenshot "
  1421. "comments."))
  1422. body_bottom = models.TextField(
  1423. _("body (bottom)"),
  1424. blank=True,
  1425. help_text=_("The review text shown below the diff and screenshot "
  1426. "comments."))
  1427. body_top_reply_to = models.ForeignKey(
  1428. "self", blank=True, null=True,
  1429. related_name="body_top_replies",
  1430. verbose_name=_("body (top) reply to"),
  1431. help_text=_("The review that the body (top) field is in reply to."))
  1432. body_bottom_reply_to = models.ForeignKey(
  1433. "self", blank=True, null=True,
  1434. related_name="body_bottom_replies",
  1435. verbose_name=_("body (bottom) reply to"),
  1436. help_text=_("The review that the body (bottom) field is in reply to."))
  1437. comments = models.ManyToManyField(Comment, verbose_name=_("comments"),
  1438. related_name="review", blank=True)
  1439. screenshot_comments = models.ManyToManyField(
  1440. ScreenshotComment,
  1441. verbose_name=_("screenshot comments"),
  1442. related_name="review",
  1443. blank=True)
  1444. file_attachment_comments = models.ManyToManyField(
  1445. FileAttachmentComment,
  1446. verbose_name=_("file attachment comments"),
  1447. related_name="review",
  1448. blank=True)
  1449. rich_text = models.BooleanField(_("rich text"), default=True)
  1450. # XXX Deprecated. This will be removed in a future release.
  1451. reviewed_diffset = models.ForeignKey(
  1452. DiffSet, verbose_name="Reviewed Diff",
  1453. blank=True, null=True,
  1454. help_text=_("This field is unused and will be removed in a future "
  1455. "version."))
  1456. # Set this up with a ReviewManager to help prevent race conditions and
  1457. # to fix duplicate reviews.
  1458. objects = ReviewManager()
  1459. def get_participants(self):
  1460. """
  1461. Returns a list of all people who have been involved in discussing
  1462. this review.
  1463. """
  1464. # This list comprehension gives us every user in every reply,
  1465. # recursively. It looks strange and perhaps backwards, but
  1466. # works. We do it this way because get_participants gives us a
  1467. # list back, which we can't stick in as the result for a
  1468. # standard list comprehension. We could opt for a simple for
  1469. # loop and concetenate the list, but this is more fun.
  1470. return [self.user] + \
  1471. [u for reply in self.replies.all()
  1472. for u in reply.participants]
  1473. participants = property(get_participants)
  1474. def is_accessible_by(self, user):
  1475. """Returns whether the user can access this review."""
  1476. return ((self.public or self.user == user or user.is_superuser) and
  1477. self.review_request.is_accessible_by(user))
  1478. def is_mutable_by(self, user):
  1479. """Returns whether the user can modify this review."""
  1480. return ((not self.public and
  1481. (self.user == user or user.is_superuser)) and
  1482. self.review_request.is_accessible_by(user))
  1483. def __unicode__(self):
  1484. return u"Review of '%s'" % self.review_request
  1485. def is_reply(self):
  1486. """
  1487. Returns whether or not this review is a reply to another review.
  1488. """
  1489. return self.base_reply_to_id is not None
  1490. is_reply.boolean = True
  1491. def public_replies(self):
  1492. """
  1493. Returns a list of public replies to this review.
  1494. """
  1495. return self.replies.filter(public=True)
  1496. def public_body_top_replies(self, user=None):
  1497. """Returns a list of public replies to this review's body top."""
  1498. if hasattr(self, '_body_top_replies'):
  1499. return self._body_top_replies
  1500. else:
  1501. q = Q(public=True)
  1502. if user:
  1503. q = q | Q(user=user)
  1504. return self.body_top_replies.filter(q)
  1505. def public_body_bottom_replies(self, user=None):
  1506. """Returns a list of public replies to this review's body bottom."""
  1507. if hasattr(self, '_body_bottom_replies'):
  1508. return self._body_bottom_replies
  1509. else:
  1510. q = Q(public=True)
  1511. if user:
  1512. q = q | Q(user=user)
  1513. return self.body_bottom_replies.filter(q)
  1514. def get_pending_reply(self, user):
  1515. """
  1516. Returns the pending reply to this review owned by the specified
  1517. user, if any.
  1518. """
  1519. if user.is_authenticated():
  1520. return get_object_or_none(Review,
  1521. user=user,
  1522. public=False,
  1523. base_reply_to=self)
  1524. return None
  1525. def save(self, **kwargs):
  1526. self.timestamp = timezone.now()
  1527. super(Review, self).save()
  1528. def publish(self, user=None):
  1529. """
  1530. Publishes this review.
  1531. This will make the review public and update the timestamps of all
  1532. contained comments.
  1533. """
  1534. if not user:
  1535. user = self.user
  1536. self.public = True
  1537. self.save()
  1538. for comment in self.comments.all():
  1539. comment.timetamp = self.timestamp
  1540. comment.save()
  1541. for comment in self.screenshot_comments.all():
  1542. comment.timetamp = self.timestamp
  1543. comment.save()
  1544. for comment in self.file_attachment_comments.all():
  1545. comment.timetamp = self.timestamp
  1546. comment.save()
  1547. # Update the last_updated timestamp and the last review activity
  1548. # timestamp on the review request.
  1549. self.review_request.last_review_activity_timestamp = self.timestamp
  1550. self.review_request.save()
  1551. # Atomicly update the shipit_count
  1552. if self.ship_it:
  1553. self.review_request.increment_shipit_count()
  1554. if self.is_reply():
  1555. reply_published.send(sender=self.__class__,
  1556. user=user, reply=self)
  1557. else:
  1558. review_published.send(sender=self.__class__,
  1559. user=user, review=self)
  1560. def delete(self):
  1561. """
  1562. Deletes this review.
  1563. This will enforce that all contained comments are also deleted.
  1564. """
  1565. for comment in self.comments.all():
  1566. comment.delete()
  1567. for comment in self.screenshot_comments.all():
  1568. comment.delete()
  1569. for comment in self.file_attachment_comments.all():
  1570. comment.delete()
  1571. super(Review, self).delete()
  1572. def get_absolute_url(self):
  1573. return "%s#review%s" % (self.review_request.get_absolute_url(),
  1574. self.pk)
  1575. def get_all_comments(self, **kwargs):
  1576. """Return a list of all contained comments of all types."""
  1577. return (list(self.comments.filter(**kwargs)) +
  1578. list(self.screenshot_comments.filter(**kwargs)) +
  1579. list(self.file_attachment_comments.filter(**kwargs)))
  1580. class Meta:
  1581. ordering = ['timestamp']
  1582. get_latest_by = 'timestamp'