summaryrefslogtreecommitdiffstats
path: root/webapp/codereview/models.py
diff options
context:
space:
mode:
Diffstat (limited to 'webapp/codereview/models.py')
-rw-r--r--webapp/codereview/models.py1480
1 files changed, 1480 insertions, 0 deletions
diff --git a/webapp/codereview/models.py b/webapp/codereview/models.py
new file mode 100644
index 0000000000..60a0515b0a
--- /dev/null
+++ b/webapp/codereview/models.py
@@ -0,0 +1,1480 @@
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""App Engine data model (schema) definition for Gerrit."""
+
+# Python imports
+import base64
+import datetime
+import hashlib
+import logging
+import random
+import re
+import zlib
+
+# AppEngine imports
+from google.appengine.ext import db
+from google.appengine.api import memcache
+from google.appengine.api import users
+
+# Local imports
+from memcache import Key as MemCacheKey
+import patching
+
+
+DEFAULT_CONTEXT = 10
+CONTEXT_CHOICES = (3, 10, 25, 50, 75, 100)
+FETCH_MAX = 1000
+MAX_DELTA_DEPTH = 10
+
+LGTM_CHOICES = (
+ ('lgtm', 'Looks good to me, approved.'),
+ ('yes', 'Looks good to me, but someone else must approve.'),
+ ('abstain', 'No score.'),
+ ('no', 'I would prefer that you didn\'t submit this.'),
+ ('reject', 'Do not submit.'),
+ )
+LIMITED_LGTM_CHOICES = [choice for choice in LGTM_CHOICES
+ if choice[0] != 'lgtm' and choice[0] != 'reject']
+
+### GQL query cache ###
+
+
+_query_cache = {}
+
+class BackedUpModel(db.Model):
+ """Base class for our models that keeps a property used for backup."""
+
+ last_backed_up = db.IntegerProperty(default=0)
+
+ def __init__(self, *args, **kwargs):
+ db.Model.__init__(self, *args, **kwargs)
+
+def gql(cls, clause, *args, **kwds):
+ """Return a query object, from the cache if possible.
+
+ Args:
+ cls: a BackedUpModel subclass.
+ clause: a query clause, e.g. 'WHERE draft = TRUE'.
+ *args, **kwds: positional and keyword arguments to be bound to the query.
+
+ Returns:
+ A db.GqlQuery instance corresponding to the query with *args and
+ **kwds bound to the query.
+ """
+ query_string = 'SELECT * FROM %s %s' % (cls.kind(), clause)
+ query = _query_cache.get(query_string)
+ if query is None:
+ _query_cache[query_string] = query = db.GqlQuery(query_string)
+ query.bind(*args, **kwds)
+ return query
+
+
+### Exceptions ###
+
+class InvalidLgtmException(Exception):
+ """User is not alloewd to LGTM this change."""
+
+class InvalidVerifierException(Exception):
+ """User is not alloewd to verify this change."""
+
+class InvalidSubmitMergeException(Exception):
+ """The change cannot me scheduled for merge."""
+
+class DeltaPatchingException(Exception):
+ """Applying a patch yield the wrong hash."""
+
+
+### Settings ###
+
+def _genkey(n=26):
+ k = ''.join(map(chr, (random.randrange(256) for i in xrange(n))))
+ return base64.b64encode(k)
+
+class Settings(BackedUpModel):
+ """Global settings for the application instance."""
+
+ analytics = db.StringProperty()
+ internal_api_key = db.StringProperty()
+ xsrf_key = db.StringProperty()
+ from_email = db.StringProperty()
+
+ _Key = MemCacheKey('Settings_Singleton')
+
+ @classmethod
+ def get_settings(cls):
+ """Get the Settings singleton.
+
+ If possible, get it from memcache. If it's not there, it tries to do a
+ normal get(). Only if that fails does it call get_or_insert, because of
+ possible contention errors due to get_or_insert's transaction.
+ """
+ def read():
+ result = cls.get_by_key_name('settings')
+ if result:
+ return result
+ else:
+ return cls.get_or_insert('settings',
+ internal_api_key=_genkey(26),
+ xsrf_key=_genkey(26))
+ return Settings._Key.get(read)
+
+ def put(self):
+ BackedUpModel.put(self)
+ self._Key.clear()
+
+
+### Approval rights ###
+
+def _flatten_users_and_groups(users, groups):
+ """Returns a set of the users and the groups provided"""
+ result = set()
+ for user in users:
+ result.add(user)
+ if groups:
+ for group in db.get(groups):
+ for user in group.members:
+ result.add(user)
+ return result
+
+
+class ApprovalRight(BackedUpModel):
+ """The tuple of a set of path patterns and a set of users who can approve
+ changes for those paths."""
+ files = db.StringListProperty()
+ approvers_users = db.ListProperty(users.User)
+ approvers_groups = db.ListProperty(db.Key)
+ verifiers_users = db.ListProperty(users.User)
+ verifiers_groups = db.ListProperty(db.Key)
+ required = db.BooleanProperty()
+
+ def approvers(self):
+ """Returns a set of the users who are approvers."""
+ return _flatten_users_and_groups(self.approvers_users,
+ self.approvers_groups)
+
+ def verifiers(self):
+ """Returns a set of the users who are verifiers."""
+ return _flatten_users_and_groups(self.verifiers_users,
+ self.verifiers_groups)
+
+ @classmethod
+ def validate_file(cls, file):
+ """Returns whether this is a valid file path.
+
+ The rules:
+ - The length must be > 0
+ - The file path must start with a '/'
+ - The file path must contain either 0 or 1 '...'
+ - If it contains one '...', it must either be last or directly
+ after the first '/'
+
+ These last two limitations could be removed someday but are
+ good enough for now.
+ """
+ if len(file) == 0:
+ return False
+ if file[0] != '/':
+ return False
+ (before, during, after) = file.partition("...")
+ if during == "" and after == "":
+ return True
+ if before != "/":
+ return False
+ if after.find("...") != -1:
+ return False
+ return True
+
+### Projects ###
+
+class Project(BackedUpModel):
+ """An open source project.
+
+ Projects have owners who can set approvers and stuff.
+ """
+
+ name = db.StringProperty(required=True)
+ comment = db.StringProperty(required=False)
+ owners_users = db.ListProperty(users.User)
+ owners_groups = db.ListProperty(db.Key)
+ code_reviews = db.ListProperty(db.Key)
+
+ @classmethod
+ def get_all_projects(cls):
+ """Return all projects"""
+ all = cls.all()
+ all.order('name')
+ return list(all)
+
+ @classmethod
+ def get_project_for_name(cls, name):
+ return cls.gql('WHERE name=:name', name=name).get()
+
+ def remove(self):
+ """delete this project"""
+ db.delete(ApprovalRight.get(self.code_reviews))
+ self.delete()
+
+ def set_code_reviews(self, approval_right_keys):
+ for key in self.code_reviews:
+ val = ApprovalRight.get(key)
+ if val:
+ db.delete(val)
+ self.code_reviews = approval_right_keys
+
+ def get_code_reviews(self):
+ return [ApprovalRight.get(key) for key in self.code_reviews]
+
+ def is_code_reviewed(self):
+ return True
+
+ def put(self):
+ memcache.flush_all()
+ BackedUpModel.put(self)
+
+ @classmethod
+ def projects_owned_by_user(cls, user):
+ memcache.flush_all()
+ key = "projects_owned_by_user_%s" % user.email()
+ result = memcache.get(key)
+ if result is None:
+ result = set(
+ Project.gql("WHERE owner_users=:user", user=user).fetch(1000))
+ groups = AccountGroup.gql("WHERE members=:user", user=user).fetch(1000)
+ if groups:
+ pr = Project.gql(
+ "WHERE owners_groups IN :groups",
+ groups=[g.key() for g in groups]).fetch(1000)
+ result.update(pr)
+ memcache.set(key, [p.key() for p in result])
+ return result
+
+
+class Branch(BackedUpModel):
+ """A branch in a specific Project."""
+
+ project = db.ReferenceProperty(Project, required=True)
+ name = db.StringProperty(required=True) # == key
+
+ status = db.StringProperty(choices=('NEEDS_MERGE',
+ 'MERGING',
+ 'BUILDING'))
+ merge_submitted = db.DateTimeProperty()
+ to_merge = db.ListProperty(db.Key) # PatchSets
+ merging = db.ListProperty(db.Key) # PatchSets
+ waiting = db.ListProperty(db.Key) # PatchSets
+
+ @classmethod
+ def get_or_insert_branch(cls, project, name):
+ key = 'p.%s %s' % (project.key().id(), name)
+ return cls.get_or_insert(key, project=project, name=name)
+
+ @classmethod
+ def get_branch_for_name(cls, project, name):
+ key = 'p.%s %s' % (project.key().id(), name)
+ return cls.get_by_key_name(key)
+
+ @property
+ def short_name(self):
+ if self.name.startswith("refs/heads/"):
+ return self.name[len("refs/heads/"):]
+ return self.name
+
+ def is_code_reviewed(self):
+ return True
+
+ def merge_patchset(self, patchset):
+ """Add a patchset to the end of the branch's merge queue
+
+ This method runs in an independent transaction.
+ """
+ ps_key = patchset.key()
+ def trans(key):
+ b = db.get(key)
+ if not ps_key in b.to_merge:
+ b.to_merge.append(ps_key)
+ if b.status is None:
+ b.status = 'NEEDS_MERGE'
+ b.merge_submitted = datetime.datetime.now()
+ b.put()
+ db.run_in_transaction(trans, self.key())
+
+ def begin_merge(self):
+ """Lock this branch and start merging patchsets.
+
+ This method runs in an independent transaction.
+ """
+ def trans(key):
+ b = db.get(key)
+ if b.status == 'NEEDS_MERGE':
+ b.status = 'MERGING'
+ b.merging.extend(b.waiting)
+ b.merging.extend(b.to_merge)
+ b.waiting = []
+ b.to_merge = []
+ b.put()
+ return b.merging
+ return []
+ keys = db.run_in_transaction(trans, self.key())
+ objs = db.get(keys)
+
+ good = []
+ torm = []
+ for k, o in zip(keys, objs):
+ if o:
+ good.append(o)
+ else:
+ torm.append(k)
+
+ if torm:
+ def clear_branch(key):
+ b = db.get(key)
+
+ for ps_key in torm:
+ if ps_key in b.merging:
+ b.merging.remove(ps_key)
+
+ if not good and b.status in ('MERGING', 'BUILDING'):
+ if b.to_merge:
+ b.status = 'NEEDS_MERGE'
+ else:
+ b.status = None
+
+ b.put()
+ db.run_in_transaction(clear_branch, self.key())
+ return good
+
+ def finish_merge(self, success, fail, defer):
+ """Update our patchset lists with the results of a merge.
+
+ This method runs in an independent transaction.
+ """
+ def trans(key):
+ b = db.get(key)
+
+ rm = []
+ rm.extend(success)
+ rm.extend(fail)
+ for ps in rm:
+ ps_key = ps.key()
+
+ if ps_key in b.to_merge:
+ b.to_merge.remove(ps_key)
+ if ps_key in b.merging:
+ b.merging.remove(ps_key)
+ if ps_key in b.waiting:
+ b.waiting.remove(ps_key)
+
+ for ps in defer:
+ ps_key = ps.key()
+
+ if ps_key in b.to_merge:
+ b.to_merge.remove(ps_key)
+ if ps_key in b.merging:
+ b.merging.remove(ps_key)
+ if ps_key not in b.waiting:
+ b.waiting.append(ps_key)
+
+ b.put()
+ db.run_in_transaction(trans, self.key())
+
+ def merged(self, merged):
+ """Updates the branch to include pending PatchSets.
+
+ This method runs in an independent transaction.
+ """
+ def trans(key):
+ b = db.get(key)
+
+ for ps in merged:
+ ps_key = ps.key()
+
+ if ps_key in b.to_merge:
+ b.to_merge.remove(ps_key)
+ if ps_key in b.merging:
+ b.merging.remove(ps_key)
+ if ps_key in b.waiting:
+ b.waiting.remove(ps_key)
+
+ if b.status in ('MERGING', 'BUILDING'):
+ if b.to_merge:
+ b.status = 'NEEDS_MERGE'
+ else:
+ b.status = None
+ b.put()
+ db.run_in_transaction(trans, self.key())
+
+
+### Revisions ###
+
+class RevisionId(BackedUpModel):
+ """A specific revision of a project."""
+
+ project = db.ReferenceProperty(Project, required=True)
+ id = db.StringProperty(required=True) # == key
+
+ author_name = db.StringProperty()
+ author_email = db.EmailProperty()
+ author_when = db.DateTimeProperty()
+ author_tz = db.IntegerProperty()
+
+ committer_name = db.StringProperty()
+ committer_email = db.EmailProperty()
+ committer_when = db.DateTimeProperty()
+ committer_tz = db.IntegerProperty()
+
+ ancestors = db.StringListProperty() # other RevisionId.id
+ message = db.TextProperty()
+
+ patchset_key = db.StringProperty()
+ def _get_patchset(self):
+ try:
+ return self._patchset_obj
+ except AttributeError:
+ k_str = self._patchset_key
+ if k_str:
+ self._patchset_obj = db.get(db.Key(k_str))
+ else:
+ self._patchset_obj = None
+ return self._patchset_obj
+
+ def _set_patchset(self, p):
+ if p is None:
+ self._patchset_key = None
+ self._patchset_obj = None
+ else:
+ self._patchset_key = str(p.key())
+ self._patchset_obj = p
+ patchset = property(_get_patchset, _set_patchset)
+
+ @classmethod
+ def get_or_insert_revision(cls, project, id, **kw):
+ key = 'p.%s %s' % (project.key().id(), id)
+ return cls.get_or_insert(key, project=project, id=id, **kw)
+
+ @classmethod
+ def get_revision(cls, project, id):
+ key = 'p.%s %s' % (project.key().id(), id)
+ return cls.get_by_key_name(key)
+
+ @classmethod
+ def get_for_patchset(cls, patchset):
+ """Get all revisions linked to a patchset.
+ """
+ return gql(cls, 'WHERE patchset_key = :1', str(patchset.key()))
+
+ def add_ancestor(self, other_id):
+ """Adds the other revision as an ancestor for this one.
+
+ If the other rev is already in the list, does nothing.
+ """
+ if not other_id in self.ancestors:
+ self.ancestors.append(other_id)
+
+ def remove_ancestor(self, other_id):
+ """Removes an ancestor previously stored.
+
+ If the other rev is not already in the list, does nothing.
+ """
+ if other_id in self.ancestors:
+ self.ancestors.remove(other_id)
+
+ def get_ancestors(self):
+ """Fully fetches all ancestors from the data store.
+ """
+ p_id = self.project.key().id()
+ names = ['p.%s %s' % (p_id, i) for i in self.ancestors]
+ return [r for r in RevisionId.get_by_key_name(names) if r]
+
+ def get_children(self):
+ """Obtain the revisions that depend upon this one.
+ """
+ return gql(RevisionId,
+ 'WHERE project = :1 AND ancestors = :2',
+ self.project, self.id)
+
+ def link_patchset(self, new_patchset):
+ """Uniquely connect one patchset to this revision.
+
+ Returns True if the passed patchset is the single patchset;
+ False if another patchset has already been linked onto it.
+ """
+ def trans(self_key):
+ c = db.get(self_key)
+ if c.patchset is None:
+ c.patchset = new_patchset
+ c.put()
+ return True
+ return False
+ return db.run_in_transaction(trans, self.key())
+
+
+class BuildAttempt(BackedUpModel):
+ """A specific build attempt."""
+
+ branch = db.ReferenceProperty(Branch, required=True)
+ revision_id = db.StringProperty(required=True)
+ new_changes = db.ListProperty(db.Key) # PatchSet
+
+ started = db.DateTimeProperty(auto_now_add=True)
+ finished = db.BooleanProperty(default=False)
+ success = db.BooleanProperty()
+
+
+### Changes, PatchSets, Patches, DeltaContents, Comments, Messages ###
+
+class Change(BackedUpModel):
+ """The major top-level entity.
+
+ It has one or more PatchSets as its descendants.
+ """
+
+ subject = db.StringProperty(required=True)
+ description = db.TextProperty()
+ owner = db.UserProperty(required=True)
+ created = db.DateTimeProperty(auto_now_add=True)
+ modified = db.DateTimeProperty(auto_now=True)
+ reviewers = db.ListProperty(db.Email)
+ claimed = db.BooleanProperty(default=False)
+ cc = db.ListProperty(db.Email)
+ closed = db.BooleanProperty(default=False)
+ n_comments = db.IntegerProperty(default=0)
+ n_patchsets = db.IntegerProperty(default=0)
+
+ dest_project = db.ReferenceProperty(Project, required=True)
+ dest_branch = db.ReferenceProperty(Branch, required=True)
+
+ merge_submitted = db.DateTimeProperty()
+ merged = db.BooleanProperty(default=False)
+
+ emailed_clean_merge = db.BooleanProperty(default=False)
+ emailed_missing_dependency = db.BooleanProperty(default=False)
+ emailed_path_conflict = db.BooleanProperty(default=False)
+
+ merge_patchset_key = db.StringProperty()
+ def _get_merge_patchset(self):
+ try:
+ return self._merge_patchset_obj
+ except AttributeError:
+ k_str = self._merge_patchset_key
+ if k_str:
+ self._merge_patchset_obj = db.get(db.Key(k_str))
+ else:
+ self._merge_patchset_obj = None
+ return self._merge_patchset_obj
+
+ def _set_merge_patchset(self, p):
+ if p is None:
+ self._merge_patchset_key = None
+ self._merge_patchset_obj = None
+ else:
+ self._merge_patchset_key = str(p.key())
+ self._merge_patchset_obj = p
+ merge_patchset = property(_get_merge_patchset, _set_merge_patchset)
+
+ _is_starred = None
+
+ @property
+ def is_starred(self):
+ """Whether the current user has this change starred."""
+ if self._is_starred is not None:
+ return self._is_starred
+ account = Account.current_user_account
+ self._is_starred = account is not None and self.key().id() in account.stars
+ return self._is_starred
+
+ def update_comment_count(self, n):
+ """Increment the n_comments property by n.
+ """
+ self.n_comments += n
+
+ @property
+ def num_comments(self):
+ """The number of non-draft comments for this change.
+
+ This is almost an alias for self.n_comments, except that if
+ n_comments is None, it is computed through a query, and stored,
+ using n_comments as a cache.
+ """
+ return self.n_comments
+
+ _num_drafts = None
+
+ @property
+ def num_drafts(self):
+ """The number of draft comments on this change for the current user.
+
+ The value is expensive to compute, so it is cached.
+ """
+ if self._num_drafts is None:
+ account = Account.current_user_account
+ if account is None:
+ self._num_drafts = 0
+ else:
+ query = gql(Comment,
+ 'WHERE ANCESTOR IS :1 AND author = :2 AND draft = TRUE',
+ self, account.user)
+ self._num_drafts = query.count()
+ return self._num_drafts
+
+ def new_patchset(self, **kw):
+ """Construct a new patchset for this change.
+ """
+ def trans(change_key):
+ change = db.get(change_key)
+ change.n_patchsets += 1
+ id = change.n_patchsets
+ change.put()
+
+ patchset = PatchSet(change=change, parent=change, id=id, **kw)
+ patchset.put()
+ return patchset
+ return db.run_in_transaction(trans, self.key())
+
+ def set_review_status(self, user):
+ """Gets or inserts the ReviewStatus object for the suppiled user."""
+ return ReviewStatus.get_or_insert_status(self, user)
+
+ def get_review_status(self, user=None):
+ """Return the lgtm status for the given user if supplied. All for this
+ change otherwise."""
+ if user:
+ # The owner must be checked separately because she automatically
+ # approves / verifies her own change and there is no ReviewStatus
+ # for that one.
+ if user == self.owner:
+ return []
+ return ReviewStatus.get_status_for_user(self, user)
+ else:
+ return ReviewStatus.all_for_change(self)
+
+ @classmethod
+ def get_reviewer_status(cls, reviews):
+ """Return a tuple of who has commented on the changes.
+
+ The owner of the change is automatically added to the list
+
+ Args:
+ reviews a list of ReviewStatus objects are returned from
+ get_review_status().
+
+ Returns:
+ A map of the LGTM_CHOICES keys to users, plus the mapping
+ verified_by --> the uesrs who verified it
+ """
+ result = {}
+ for (k,v) in LGTM_CHOICES:
+ result[k] = [r.user for r in reviews if r.lgtm == k]
+ result["verified_by"] = [r.user for r in reviews if r.verified]
+ return result
+
+ @property
+ def is_submitted(self):
+ """Return true if the change has been submitted for merge.
+ """
+ return self.merge_submitted is not None
+
+ def submit_merge(self, patchset):
+ """Schedule a specific patchset of the change to be merged.
+ """
+ branch = self.dest_branch
+ if not branch:
+ raise InvalidSubmitMergeException, 'No branch defined'
+
+ if self.merged:
+ raise InvalidSubmitMergeException, 'Already merged'
+
+ if self.is_submitted:
+ raise InvalidSubmitMergeException, \
+ "Already merging patch set %s" % self.merge_patchset.id
+
+ branch.merge_patchset(patchset)
+ self.merge_submitted = datetime.datetime.now()
+ self.merge_patchset = patchset
+ self.emailed_clean_merge = False
+ self.emailed_missing_dependency = False
+ self.emailed_path_conflict = False
+
+ def unsubmit_merge(self):
+ """Unschedule a merge of this change.
+ """
+ if self.merged:
+ raise InvalidSubmitMergeException, 'Already merged'
+
+ self.merge_submitted = None
+ self.merge_patchset = None
+
+ def set_reviewers(self, reviewers):
+ self.reviewers = reviewers
+ self.claimed = len(reviewers) != 0
+
+
+class PatchSetFilenames(BackedUpModel):
+ """A list of the file names in a PatchSet.
+
+ This is a descendant of a PatchSet.
+ """
+
+ compressed_filenames = db.BlobProperty()
+
+ @classmethod
+ def _mc(cls, patchset):
+ return MemCacheKey("PatchSet %s filenames" % patchset.key())
+
+ @classmethod
+ def store_compressed(cls, patchset, bin):
+ cls(key_name = 'filenames',
+ compressed_filenames = db.Blob(bin),
+ parent = patchset).put()
+ cls._mc(patchset).set(cls._split(bin))
+
+ @classmethod
+ def get_list(cls, patchset):
+ def read():
+ c = cls.get_by_key_name('filenames', parent = patchset)
+ if c:
+ return cls._split(c.compressed_filenames)
+ names = patchset._all_filenames()
+ bin = zlib.compress("\0".join(names).encode('utf_8'), 9)
+ cls(key_name = 'filenames',
+ compressed_filenames = db.Blob(bin),
+ parent = patchset).put()
+ return names
+ return cls._mc(patchset).get(read)
+
+ @classmethod
+ def _split(cls, bin):
+ tmp = zlib.decompress(bin).split("\0")
+ return [s.decode('utf_8') for s in tmp]
+
+
+class PatchSet(BackedUpModel):
+ """A set of patchset uploaded together.
+
+ This is a descendant of an Change and has Patches as descendants.
+ """
+
+ id = db.IntegerProperty(required=True)
+ change = db.ReferenceProperty(Change, required=True) # == parent
+ message = db.StringProperty()
+ owner = db.UserProperty(required=True)
+ created = db.DateTimeProperty(auto_now_add=True)
+ modified = db.DateTimeProperty(auto_now=True)
+ revision = db.ReferenceProperty(RevisionId, required=True)
+ complete = db.BooleanProperty(default=False)
+
+ _filenames = None
+
+ @property
+ def filenames(self):
+ if self._filenames is None:
+ self._filenames = PatchSetFilenames.get_list(self)
+ return self._filenames
+
+ def _all_filenames(self):
+ last = ''
+ names = []
+ while True:
+ list = gql(Patch,
+ 'WHERE patchset = :1 AND filename > :2'
+ ' ORDER BY filename',
+ self, last).fetch(500)
+ if not list:
+ break
+ for p in list:
+ names.append(p.filename)
+ last = list[-1].filename
+ return names
+
+
+class Message(BackedUpModel):
+ """A copy of a message sent out in email.
+
+ This is a descendant of an Change.
+ """
+
+ change = db.ReferenceProperty(Change, required=True) # == parent
+ subject = db.StringProperty()
+ sender = db.EmailProperty()
+ recipients = db.ListProperty(db.Email)
+ date = db.DateTimeProperty(auto_now_add=True)
+ text = db.TextProperty()
+
+
+class CachedDeltaContent(object):
+ """A fully inflated DeltaContent stored in memcache.
+ """
+ def __init__(self, dc):
+ self.data_lines = dc.data_lines
+ self.patch_lines = dc.patch_lines
+ self.is_patch = dc.is_patch
+ self.is_data = dc.is_data
+
+ @property
+ def data_text(self):
+ if self.data_lines is None:
+ return None
+ return ''.join(self.data_lines)
+
+ @property
+ def patch_text(self):
+ if self.patch_lines is None:
+ return None
+ return ''.join(self.patch_lines)
+
+ @classmethod
+ def get(cls, key):
+ def load():
+ dc = db.get(key)
+ if dc:
+ return cls(dc)
+ return None
+ return MemCacheKey('DeltaContent:%s' % key.name(),
+ compress = True).get(load)
+
+
+def _apply_patch(old_lines, patch_name, dif_lines):
+ new_lines = []
+ chunks = patching.ParsePatchToChunks(dif_lines, patch_name)
+ for tag, old, new in patching.PatchChunks(old_lines, chunks):
+ new_lines.extend(new)
+ return ''.join(new_lines)
+
+def _blob_hash(data):
+ m = hashlib.sha1()
+ m.update("blob %d\0" % len(data))
+ m.update(data)
+ return m.hexdigest()
+
+
+class DeltaContent(BackedUpModel):
+ """Any content, such as for the old or new image of a Patch,
+ or the patch data itself.
+
+ These are stored as top-level entities.
+
+ Key:
+ Git blob SHA-1 of inflate(text)
+ -or-
+ Randomly generated name if this is a patch
+ """
+
+ text_z = db.BlobProperty(required=True)
+ depth = db.IntegerProperty(default=0, required=True)
+ base = db.SelfReferenceProperty()
+
+ _data_lines = None
+ _data_text = None
+ _patch_text = None
+ _patch_lines = None
+
+ @classmethod
+ def create_patch(cls, id, text_z):
+ key_name = 'patch:%s' % id
+ return cls.get_or_insert(key_name,
+ text_z = db.Blob(text_z),
+ depth = 0,
+ base = None)
+
+ @classmethod
+ def create_content(cls, id, text_z, base = None):
+ """Create (or lookup and return an existing) content instance.
+
+ Arguments:
+ id:
+ Git blob SHA-1 hash of the fully inflated content.
+ text_z:
+ If base is None this is the deflated content whose hash
+ is id.
+
+ If base is supplied this is a patch which when applied to
+ base yields the content whose hash is id.
+ base:
+ The base content if text_z is a patch.
+ """
+ key_name = 'content:%s' % id
+ r = cls.get_by_key_name(key_name)
+ if r:
+ return r
+
+ if base is None:
+ return cls.get_or_insert(key_name,
+ text_z = db.Blob(text_z),
+ depth = 0,
+ base = None)
+
+ my_text = _apply_patch(base.data_lines,
+ id,
+ zlib.decompress(text_z).splitlines(True))
+ cmp_id = _blob_hash(my_text)
+ if id != cmp_id:
+ raise DeltaPatchingException()
+
+ if base.depth < MAX_DELTA_DEPTH:
+ return cls.get_or_insert(key_name,
+ text_z = db.Blob(text_z),
+ depth = base.depth + 1,
+ base = base)
+ return cls.get_or_insert(key_name,
+ text_z = db.Blob(zlib.compress(my_text)),
+ depth = 0,
+ base = None)
+
+ @property
+ def is_patch(self):
+ return self._base or self.key().name().startswith('patch:')
+
+ @property
+ def is_data(self):
+ return self.key().name().startswith('content:')
+
+ @property
+ def data_text(self):
+ if self._data_text is None:
+ if self._base:
+ base = CachedDeltaContent.get(self._base)
+ raw = _apply_patch(base.data_lines,
+ self.key().name(),
+ self.patch_lines)
+ else:
+ raw = zlib.decompress(self.text_z)
+ self._data_text = raw
+ return self._data_text
+
+ @property
+ def data_lines(self):
+ if self._data_lines is None:
+ self._data_lines = self.data_text.splitlines(True)
+ return self._data_lines
+
+ @property
+ def patch_text(self):
+ if not self.is_patch:
+ return None
+ if self._patch_text is None:
+ self._patch_text = zlib.decompress(self.text_z)
+ return self._patch_text
+
+ @property
+ def patch_lines(self):
+ if not self.is_patch:
+ return None
+ if self._patch_lines is None:
+ self._patch_lines = self.patch_text.splitlines(True)
+ return self._patch_lines
+
+
+class Patch(BackedUpModel):
+ """A single patch, i.e. a set of changes to a single file.
+
+ This is a descendant of a PatchSet.
+ """
+
+ patchset = db.ReferenceProperty(PatchSet, required=True) # == parent
+ filename = db.StringProperty(required=True)
+ status = db.StringProperty(required=True) # 'A', 'M', 'D'
+ n_comments = db.IntegerProperty()
+
+ old_data = db.ReferenceProperty(DeltaContent, collection_name='olddata_set')
+ new_data = db.ReferenceProperty(DeltaContent, collection_name='newdata_set')
+ diff_data = db.ReferenceProperty(DeltaContent, collection_name='diffdata_set')
+
+ @classmethod
+ def get_or_insert_patch(cls, patchset, filename, **kw):
+ """Get or insert the patch for a specific file path.
+
+ This method runs in an independent transaction.
+ """
+ m = hashlib.sha1()
+ m.update(filename)
+ key = 'z%s' % m.hexdigest()
+ return cls.get_or_insert(key,
+ parent = patchset,
+ patchset = patchset,
+ filename = filename,
+ **kw)
+
+ @classmethod
+ def get_patch(cls, parent, id_str):
+ if id_str.startswith('z'):
+ return cls.get_by_key_name(id_str, parent=parent);
+ else:
+ return cls.get_by_id(int(id_str), parent=parent);
+
+ @property
+ def id(self):
+ return str(self.key().id_or_name())
+
+ @property
+ def num_comments(self):
+ """The number of non-draft comments for this patch.
+ """
+ return self.n_comments
+
+ def update_comment_count(self, n):
+ """Increment the n_comments property by n.
+ """
+ self.n_comments += n
+
+ _num_drafts = None
+
+ @property
+ def num_drafts(self):
+ """The number of draft comments on this patch for the current user.
+
+ The value is expensive to compute, so it is cached.
+ """
+ if self._num_drafts is None:
+ user = Account.current_user_account
+ if user is None:
+ self._num_drafts = 0
+ else:
+ query = gql(Comment,
+ 'WHERE patch = :1 AND draft = TRUE AND author = :2',
+ self, user.user)
+ self._num_drafts = query.count()
+ return self._num_drafts
+
+ def _data(self, name):
+ prop = '_%s_CachedDeltaContent' % name
+ try:
+ c = getattr(self, prop)
+ except AttributeError:
+ # XXX Using internal knowledge about db package:
+ # Key for ReferenceProperty 'foo' is '_foo'.
+
+ data_key = getattr(self, '_%s_data' % name, None)
+ if data_key:
+ c = CachedDeltaContent.get(data_key)
+ if data_key in ('diff', 'new') \
+ and self._diff_data == self._new_data:
+ self._diff_CachedDeltaContent = c
+ self._new_CachedDeltaContent = c
+ else:
+ setattr(self, prop, c)
+ else:
+ c = None
+ setattr(self, prop, c)
+ return c
+
+ @property
+ def patch_text(self):
+ """Get the patch converting old_text to new_text.
+ """
+ return self._data('diff').patch_text
+
+ @property
+ def patch_lines(self):
+ """The patch_text split into lines, retaining line endings.
+ """
+ return self._data('diff').patch_lines
+
+ @property
+ def old_text(self):
+ """Original version of the file text.
+ """
+ d = self._data('old')
+ if d:
+ return d.data_text
+ return ''
+
+ @property
+ def old_lines(self):
+ """The old_text split into lines, retaining line endings.
+ """
+ d = self._data('old')
+ if d:
+ return d.data_lines
+ return []
+
+ @property
+ def new_text(self):
+ """Get self.new_content
+ """
+ d = self._data('new')
+ if d:
+ return d.data_text
+ return ''
+
+ @property
+ def new_lines(self):
+ """The new_text split into lines, retaining line endings.
+ """
+ d = self._data('new')
+ if d:
+ return d.data_lines
+ return []
+
+
+class Comment(BackedUpModel):
+ """A Comment for a specific line of a specific file.
+
+ This is a descendant of a Patch.
+ """
+
+ patch = db.ReferenceProperty(Patch) # == parent
+ message_id = db.StringProperty() # == key_name
+ author = db.UserProperty()
+ date = db.DateTimeProperty(auto_now=True)
+ lineno = db.IntegerProperty()
+ text = db.TextProperty()
+ left = db.BooleanProperty()
+ draft = db.BooleanProperty(required=True, default=True)
+
+ def complete(self, patch):
+ """Set the shorttext and buckets attributes."""
+ # TODO(guido): Turn these into caching proprties instead.
+ # TODO(guido): Properly parse the text into quoted and unquoted buckets.
+ self.shorttext = self.text.lstrip()[:50].rstrip()
+ self.buckets = [Bucket(text=self.text)]
+
+
+class Bucket(BackedUpModel):
+ """A 'Bucket' of text.
+
+ A comment may consist of multiple text buckets, some of which may be
+ collapsed by default (when they represent quoted text).
+
+ NOTE: This entity is never written to the database. See Comment.complete().
+ """
+ # TODO(guido): Flesh this out.
+
+ text = db.TextProperty()
+
+
+class ReviewStatus(BackedUpModel):
+ """The information for whether a user has LGTMed or verified a change."""
+ change = db.ReferenceProperty(Change, required=True) # == parent
+ user = db.UserProperty(required=True)
+ lgtm = db.StringProperty()
+ verified = db.BooleanProperty()
+
+ @classmethod
+ def get_or_insert_status(cls, change, user):
+ key = '<%s>' % user.email
+ return cls.get_or_insert(key,
+ change=change,
+ user=user,
+ parent=change)
+
+ @classmethod
+ def get_status_for_user(cls, change, user):
+ key = '<%s>' % user.email
+ return cls.get_by_key_name(key, parent=change)
+
+ @classmethod
+ def all_for_change(cls, change):
+ return gql(ReviewStatus,
+ 'WHERE ANCESTOR IS :1',
+ change).fetch(FETCH_MAX)
+
+
+### Contributor License Agreements ###
+
+class IndividualCLA:
+ NONE = 0
+
+
+### Accounts ###
+
+
+class Account(BackedUpModel):
+ """Maps a user or email address to a user-selected real_name, and more.
+
+ Nicknames do not have to be unique.
+
+ The default real_name is generated from the email address by
+ stripping the first '@' sign and everything after it. The email
+ should not be empty nor should it start with '@' (AssertionError
+ error is raised if either of these happens).
+
+ Holds a list of ids of starred changes. The expectation
+ that you won't have more than a dozen or so starred changes (a few
+ hundred in extreme cases) and the memory used up by a list of
+ integers of that size is very modest, so this is an efficient
+ solution. (If someone found a use case for having thousands of
+ starred changes we'd have to think of a different approach.)
+
+ Returns whether a user is authorized to do lgtm or verify.
+ For now, these authorization check methods do not test which repository
+ the change is in. This will change.
+ """
+
+ user = db.UserProperty(required=True)
+ email = db.EmailProperty(required=True) # key == <email>
+ preferred_email = db.EmailProperty()
+
+ created = db.DateTimeProperty(auto_now_add=True)
+ modified = db.DateTimeProperty(auto_now=True)
+
+ welcomed = db.BooleanProperty(default=False)
+ real_name_entered = db.BooleanProperty(default=False)
+ real_name = db.StringProperty()
+ mailing_address = db.TextProperty()
+ mailing_address_country = db.StringProperty()
+ phone_number = db.StringProperty()
+ fax_number = db.StringProperty()
+
+ cla_verified = db.BooleanProperty(default=False)
+ cla_verified_by = db.UserProperty()
+ cla_verified_timestamp = db.DateTimeProperty() # the first time it's set
+ individual_cla_version = db.IntegerProperty(default=IndividualCLA.NONE)
+ individual_cla_timestamp = db.DateTimeProperty()
+ cla_comments = db.TextProperty()
+
+ default_context = db.IntegerProperty(default=DEFAULT_CONTEXT,
+ choices=CONTEXT_CHOICES)
+ stars = db.ListProperty(int) # Change ids of all starred changes
+ unclaimed_changes_projects = db.ListProperty(db.Key)
+
+ # Current user's Account. Updated by middleware.AddUserToRequestMiddleware.
+ current_user_account = None
+
+ def get_email(self):
+ "Gets the email that this person wants us to use -- separate from login."
+ if self.preferred_email:
+ return self.preferred_email
+ else:
+ return self.email
+
+ def get_email_formatted(self):
+ return '"%s" <%s>' % (self.real_name, self.get_email())
+
+ @classmethod
+ def get_account_for_user(cls, user):
+ """Get the Account for a user, creating a default one if needed."""
+ email = user.email()
+ assert email
+ key = '<%s>' % email
+ # Since usually the account already exists, first try getting it
+ # without the transaction implied by get_or_insert().
+ account = cls.get_by_key_name(key)
+ if account is not None:
+ return account
+ real_name = user.nickname()
+ if '@' in real_name:
+ real_name = real_name.split('@', 1)[0]
+ assert real_name
+ return cls.get_or_insert(key, user=user, email=email, real_name=real_name)
+
+ @classmethod
+ def get_account_for_email(cls, email):
+ """Get the Account for an email address, or return None."""
+ assert email
+ key = '<%s>' % email
+ return cls.get_by_key_name(key)
+
+ @classmethod
+ def get_accounts_for_emails(cls, emails):
+ """Get the Accounts for all email address.
+ """
+ return cls.get_by_key_name(map(lambda x: '<%s>' % x, emails))
+
+ @classmethod
+ def get_real_name_for_email(cls, email):
+ """Get the real_name for an email address, possibly a default."""
+ account = cls.get_account_for_email(email)
+ if account is not None and account.real_name:
+ return account.real_name
+ real_name = email
+ if '@' in real_name:
+ real_name = real_name.split('@', 1)[0]
+ assert real_name
+ return real_name
+
+ @classmethod
+ def get_accounts_for_real_name(cls, real_name):
+ """Get the list of Accounts that have this real_name."""
+ assert real_name
+ assert '@' not in real_name
+ return list(gql(cls, 'WHERE real_name = :1', real_name))
+
+ @classmethod
+ def get_email_for_real_name(cls, real_name):
+ """Turn a real_name into an email address.
+
+ If the real_name is not unique or does not exist, this returns None.
+ """
+ accounts = cls.get_accounts_for_real_name(real_name)
+ if len(accounts) != 1:
+ return None
+ return accounts[0].email
+
+ _drafts = None
+
+ @property
+ def drafts(self):
+ """A list of change ids that have drafts by this user.
+
+ This is cached in memcache.
+ """
+ if self._drafts is None:
+ if self._initialize_drafts():
+ self._save_drafts()
+ return self._drafts
+
+ def update_drafts(self, change, have_drafts=None):
+ """Update the user's draft status for this change.
+
+ Args:
+ change: an Change instance.
+ have_drafts: optional bool forcing the draft status. By default,
+ change.num_drafts is inspected (which may query the datastore).
+
+ The Account is written to the datastore if necessary.
+ """
+ dirty = False
+ if self._drafts is None:
+ dirty = self._initialize_drafts()
+ id = change.key().id()
+ if have_drafts is None:
+ have_drafts = bool(change.num_drafts) # Beware, this may do a query.
+ if have_drafts:
+ if id not in self._drafts:
+ self._drafts.append(id)
+ dirty = True
+ else:
+ if id in self._drafts:
+ self._drafts.remove(id)
+ dirty = True
+ if dirty:
+ self._save_drafts()
+
+ def _initialize_drafts(self):
+ """Initialize self._drafts from scratch.
+
+ This mostly exists as a schema conversion utility.
+
+ Returns:
+ True if the user should call self._save_drafts(), False if not.
+ """
+ drafts = memcache.get('user_drafts:' + self.email)
+ if drafts is not None:
+ self._drafts = drafts
+ return False
+ # We're looking for the Change key id. The ancestry of comments goes:
+ # Change -> PatchSet -> Patch -> Comment.
+ change_ids = set(comment.key().parent().parent().parent().id()
+ for comment in gql(Comment,
+ 'WHERE author = :1 AND draft = TRUE',
+ self.user))
+ self._drafts = list(change_ids)
+ return True
+
+ def _save_drafts(self):
+ """Save self._drafts to memcache."""
+ memcache.set('user_drafts:' + self.email, self._drafts, 3600)
+
+ def can_lgtm(self):
+ """Returns whether the user can lgtm a given change.
+
+ For now returns true and doesn't take the change to check.
+ """
+ return True
+
+ def can_verify(self):
+ """Returns whether the user can verify a given change.
+
+ For now returns true and doesn't take the change to check.
+ """
+ return True
+
+ @classmethod
+ def get_all_accounts(cls):
+ """Return all accounts"""
+ all = cls.all()
+ all.order('real_name')
+ return list(all)
+
+
+### Group ###
+
+AUTO_GROUPS = ['admin', 'submitters']
+
+class AccountGroup(BackedUpModel):
+ """A set of users. Permissions are assigned to groups.
+
+ There are some groups that can't be deleted -- like admin and all
+ """
+
+ name = db.StringProperty(required=True)
+ comment = db.TextProperty(required=False)
+ members = db.ListProperty(users.User)
+
+ @classmethod
+ def get_all_groups(cls):
+ """Return all groups"""
+ all = cls.all()
+ all.order('name')
+ return list(all)
+
+ @property
+ def is_auto_group(self):
+ """These groups can't be deleted."""
+ return self.name in AUTO_GROUPS
+
+ @classmethod
+ def create_groups(cls):
+ for group_name in AUTO_GROUPS:
+ def trans():
+ g = cls(name=group_name, comment=(
+ 'Auto created %s group' % group_name))
+ g.put()
+ q = cls.gql('WHERE name=:name', name=group_name)
+ if q.get() is None:
+ db.run_in_transaction(trans)
+
+ @classmethod
+ def get_group_for_name(cls, name):
+ return cls.gql('WHERE name=:name', name=name).get()
+
+ def remove(self):
+ """delete this group"""
+ def trans(group):
+ group.delete()
+ # this will do the ON DELETE CASCADE once the users are in there
+ db.run_in_transaction(trans, self)
+
+ def put(self):
+ if self.name in ['admin', 'submitters']:
+ memcache.delete('group_members:%s' % self.name)
+ BackedUpModel.put(self)
+
+ @classmethod
+ def _is_in_cached_group(cls, user, group):
+ if not user:
+ return False
+ cache_key = 'group_members:%s' % group
+ users = memcache.get(cache_key)
+ if not users:
+ g = AccountGroup.get_group_for_name(group)
+ if not g:
+ AccountGroup.create_groups()
+ g = AccountGroup.get_group_for_name(group)
+ if len(g.members) == 0:
+ # if there are no users in this group, everyone is in this group
+ # (helps with testing, upgrading and bootstrapping)
+ # In prod this never really happens, so don't bother caching it
+ return True
+ users = [u.email() for u in g.members]
+ memcache.set(cache_key, users)
+ return user.email() in users
+
+ @classmethod
+ def is_user_admin(cls, user):
+ return AccountGroup._is_in_cached_group(user, 'admin')
+
+ @classmethod
+ def is_user_submitter(cls, user):
+ return AccountGroup._is_in_cached_group(user, 'submitters')