PageRenderTime 128ms CodeModel.GetById 22ms RepoModel.GetById 1ms 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

Large files files are truncated, but you can click here to view the full file

  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_v

Large files files are truncated, but you can click here to view the full file