# 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() canonical_url = db.StringProperty(default='') _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 == 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')