summaryrefslogtreecommitdiffstats
path: root/webapp/codereview/views.py
diff options
context:
space:
mode:
Diffstat (limited to 'webapp/codereview/views.py')
-rw-r--r--webapp/codereview/views.py1541
1 files changed, 1541 insertions, 0 deletions
diff --git a/webapp/codereview/views.py b/webapp/codereview/views.py
new file mode 100644
index 0000000000..c4eba3fa45
--- /dev/null
+++ b/webapp/codereview/views.py
@@ -0,0 +1,1541 @@
+# 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.
+
+"""Views for Gerrit.
+
+This requires Django 0.97.pre.
+"""
+
+
+### Imports ###
+
+
+# Python imports
+import os
+import cgi
+import random
+import re
+import logging
+import binascii
+import datetime
+import hashlib
+import zlib
+from xml.etree import ElementTree
+from cStringIO import StringIO
+
+# AppEngine imports
+from google.appengine.api import mail
+from google.appengine.api import memcache
+from google.appengine.api import users
+from google.appengine.ext import db
+from google.appengine.ext.db import djangoforms
+
+# Django imports
+# TODO(guido): Don't import classes/functions directly.
+from django import forms
+from django import http
+from django.http import HttpResponse, HttpResponseRedirect
+from django.http import HttpResponseForbidden, HttpResponseNotFound
+from django.shortcuts import render_to_response
+import django.template
+from django.utils import simplejson
+from django.forms import formsets
+
+# Local imports
+from memcache import Key as MemCacheKey
+import models
+import email
+import engine
+import library
+import patching
+import fields
+import project
+import git_models
+from view_util import *
+
+
+### Constants ###
+
+MAX_ROWS = 1000
+
+
+### Helper functions ###
+
+
+def _random_bytes(n):
+ """Helper returning a string of random bytes of given length."""
+ return ''.join(map(chr, (random.randrange(256) for i in xrange(n))))
+
+
+### Request handlers ###
+
+
+def index(request):
+ """/ - Show a list of patches."""
+ if request.user is None:
+ return all(request)
+ else:
+ return mine(request)
+
+
+DEFAULT_LIMIT = 10
+
+def all(request):
+ """/all - Show a list of up to DEFAULT_LIMIT recent change."""
+ offset = request.GET.get('offset')
+ if offset:
+ try:
+ offset = int(offset)
+ except:
+ offset = 0
+ else:
+ offset = max(0, offset)
+ else:
+ offset = 0
+ limit = request.GET.get('limit')
+ if limit:
+ try:
+ limit = int(limit)
+ except:
+ limit = DEFAULT_LIMIT
+ else:
+ limit = max(1, min(limit, 100))
+ else:
+ limit = DEFAULT_LIMIT
+ query = db.GqlQuery('SELECT * FROM Change '
+ 'WHERE closed = FALSE ORDER BY modified DESC')
+ # Fetch one more to see if there should be a 'next' link
+ changes = query.fetch(limit+1, offset)
+ more = bool(changes[limit:])
+ if more:
+ del changes[limit:]
+ if more:
+ next = '/all?offset=%d&limit=%d' % (offset+limit, limit)
+ else:
+ next = ''
+ if offset > 0:
+ prev = '/all?offset=%d&limit=%d' % (max(0, offset-limit), limit)
+ else:
+ prev = ''
+ newest = ''
+ if offset > limit:
+ newest = '/all?limit=%d' % limit
+
+ _optimize_draft_counts(changes)
+ _prefetch_names(changes)
+ return respond(request, 'all.html',
+ {'changes': changes, 'limit': limit,
+ 'newest': newest, 'prev': prev, 'next': next,
+ 'first': offset+1,
+ 'last': len(changes) > 1 and offset+len(changes) or None})
+
+
+def _optimize_draft_counts(changes):
+ """Force _num_drafts to zero for changes that are known to have no drafts.
+
+ Args:
+ changes: list of model.Change instances.
+
+ This inspects the drafts attribute of the current user's Account
+ instance, and forces the draft count to zero of those changes in the
+ list that aren't mentioned there.
+
+ If there is no current user, all draft counts are forced to 0.
+ """
+ account = models.Account.current_user_account
+ if account is None:
+ change_ids = None
+ else:
+ change_ids = account.drafts
+ for change in changes:
+ if change_ids is None or change.key().id() not in change_ids:
+ change._num_drafts = 0
+
+def _prefetch_names(changes):
+ for c in changes:
+ library.prefetch_names([c.owner])
+ library.prefetch_names(c.reviewers)
+ library.prefetch_names(c.cc)
+
+
+@login_required
+def mine(request):
+ """/mine - Show a list of changes created by the current user."""
+ request.user_to_show = request.user
+ return _show_user(request)
+
+def unclaimed_project_memcache_key(user):
+ return "user_unclaimed_projects:%s" % user.email()
+
+@login_required
+def unclaimed(request):
+ """/unclaimed - Show changes with no reviewer listed for user's selected
+ projects."""
+ def _get_unclaimed_projects(user):
+ memcache_key = unclaimed_project_memcache_key(user)
+ keys = memcache.get(memcache_key)
+ if keys is None:
+ account = models.Account.get_account_for_user(user)
+ keys = account.unclaimed_changes_projects
+ err = memcache.set(memcache_key, keys)
+ result = models.Project.get(keys)
+ if not result:
+ result = []
+ return result
+
+ user = request.user
+ changes = []
+
+ projects = _get_unclaimed_projects(request.user)
+ for project in projects:
+ c = models.gql(models.Change,
+ ' WHERE closed = FALSE'
+ ' AND claimed = FALSE'
+ ' AND dest_project = :dest_project'
+ ' ORDER BY modified DESC',
+ dest_project=project.key()).fetch(1000)
+ if c:
+ _optimize_draft_counts(c)
+ _prefetch_names(c)
+ changes.append({
+ 'name': project.name,
+ 'changes': c,
+ })
+
+ vars = {
+ 'projects': changes,
+ }
+
+ return respond(request, 'unclaimed.html', vars)
+
+
+@login_required
+def starred(request):
+ """/starred - Show a list of changes starred by the current user."""
+ stars = models.Account.current_user_account.stars
+ if not stars:
+ changes = []
+ else:
+ changes = [change for change in models.Change.get_by_id(stars)
+ if change is not None]
+ _optimize_draft_counts(changes)
+ _prefetch_names(changes)
+ return respond(request, 'starred.html', {'changes': changes})
+
+
+@user_key_required
+def show_user(request):
+ """/user - Show the user's dashboard"""
+ return _show_user(request)
+
+@user_key_required
+def ajax_user_mine(request, offset_str):
+ m = _user_mine(request, request.user_to_show, int(offset_str))
+ return _respond_paged(request,'change_pagedrow.html',
+ 'change', 'mine', m)
+
+@user_key_required
+def ajax_user_review(request, offset_str):
+ m = _user_review(request, request.user_to_show, int(offset_str))
+ return _respond_paged(request,'change_pagedrow.html',
+ 'change', 'review', m)
+
+@user_key_required
+def ajax_user_closed(request, offset_str):
+ m = _user_closed(request, request.user_to_show, int(offset_str))
+ return _respond_paged(request,'change_pagedrow.html',
+ 'change', 'closed', m)
+
+def _respond_paged(request, template, new_pfx, old_pfx, vars):
+ return respond(request, template, {
+ new_pfx + '_list': vars[old_pfx + '_list'],
+ new_pfx + '_opos': vars[old_pfx + '_opos'],
+ new_pfx + '_oend': vars[old_pfx + '_oend'],
+ new_pfx + '_prev': vars[old_pfx + '_prev'],
+ new_pfx + '_next': vars[old_pfx + '_next']
+ })
+
+def _paginate(prefix, n, offset, q):
+ list = q.fetch(n + 1, offset - 1)
+ have = len(list)
+
+ if have == n + 1:
+ list = list[0:-1]
+ have = n
+ next = offset + n
+ if next >= 1000:
+ next = None
+ else:
+ next = None
+
+ if offset == 0:
+ prev = None
+ else:
+ prev = offset - n
+ if prev < 0:
+ prev = 0
+
+ if next:
+ last = next - 1
+ else:
+ last = offset + have - 1
+
+ for i in list:
+ i.paginate_row_type = prefix
+
+ return {prefix + '_list': list,
+ prefix + '_opos': offset,
+ prefix + '_oend': last,
+ prefix + '_prev': prev,
+ prefix + '_next': next}
+
+
+def _user_mine(request, user, offset):
+ r = _paginate('mine', 10, offset,
+ models.gql(models.Change,
+ 'WHERE closed = FALSE AND owner = :1'
+ ' ORDER BY modified DESC',
+ user))
+ _optimize_draft_counts(r['mine_list'])
+ _prefetch_names(r['mine_list'])
+ return r
+
+def _user_review(request, user, offset):
+ r = _paginate('review', 10, offset,
+ models.gql(models.Change,
+ 'WHERE closed = FALSE AND reviewers = :1'
+ ' ORDER BY modified DESC',
+ user.email()))
+ _optimize_draft_counts(r['review_list'])
+ _prefetch_names(r['review_list'])
+ return r
+
+def _user_closed(request, user, offset):
+ r = _paginate('closed', 10, offset,
+ models.gql(models.Change,
+ 'WHERE closed = TRUE AND owner = :1 AND modified > :2'
+ ' ORDER BY modified DESC',
+ user,
+ datetime.datetime.now() - datetime.timedelta(days=7)
+ ))
+ _optimize_draft_counts(r['closed_list'])
+ _prefetch_names(r['closed_list'])
+ return r
+
+def _show_user(request):
+ user = request.user_to_show
+
+ mine = _user_mine(request, user, 1)
+ review = _user_review(request, user, 1)
+ closed = _user_closed(request, user, 1)
+
+ vars = {'email': user.email()}
+ vars.update(mine)
+ vars.update(review)
+ vars.update(closed)
+
+ return respond(request, 'user.html', vars)
+
+
+def _get_emails(form, label):
+ """Helper to return the list of reviewers, or None for error."""
+ emails = []
+ raw_emails = form.cleaned_data.get(label)
+ if raw_emails:
+ for email in raw_emails.split(','):
+ email = email.strip().lower()
+ if email and email not in emails:
+ try:
+ email = db.Email(email)
+ if email.count('@') != 1:
+ raise db.BadValueError('Invalid email address: %s' % email)
+ head, tail = email.split('@')
+ if '.' not in tail:
+ raise db.BadValueError('Invalid email address: %s' % email)
+ except db.BadValueError, err:
+ form.errors[label] = [unicode(err)]
+ continue
+ emails.append(email)
+ return emails
+
+def _prepare_show_patchset(user, patchset):
+ if user:
+ drafts = list(models.gql(models.Comment,
+ 'WHERE ANCESTOR IS :1'
+ ' AND draft = TRUE'
+ ' AND author = :2',
+ patchset, user))
+ else:
+ drafts = []
+
+ max_rows = 100
+ if len(patchset.filenames) < max_rows:
+ files = models.gql(models.Patch,
+ 'WHERE patchset = :1 ORDER BY filename',
+ patchset).fetch(max_rows)
+ patchset.n_comments = 0
+ patchset.n_drafts = len(drafts)
+ patchset.patches = files
+
+ if drafts:
+ p_bykey = dict()
+ for p in patchset.patches:
+ p_bykey[p.key()] = p
+ p._num_drafts = 0
+ patchset.n_comments += p.num_comments
+
+ for d in drafts:
+ if d.parent_key() in p_bykey:
+ p = p_bykey[d.parent_key()]
+ p._num_drafts += 1
+ else:
+ for p in patchset.patches:
+ p._num_drafts = 0
+ else:
+ patchset.freaking_huge = True
+ patchset.patches = []
+
+
+def _restrict_lgtm(lgtm, can_approve):
+ if not can_approve:
+ if lgtm == 'lgtm':
+ return 'yes'
+ elif lgtm == 'reject':
+ return 'no'
+ return lgtm
+
+
+def _restrict_verified(verified, can_verify):
+ if can_verify:
+ return verified
+ else:
+ return False
+
+def _map_status(rs, real_approvers, real_deniers, real_verifiers):
+ email = rs.user.email()
+ lgtm = _restrict_lgtm(rs.lgtm,
+ (email in real_approvers) or (email in real_deniers))
+ verified = _restrict_verified(rs.verified, email in real_verifiers)
+ return {
+ 'user': rs.user,
+ 'lgtm': lgtm,
+ 'verified': verified,
+ }
+
+@change_required
+def show(request, form=None):
+ """/<change> - Show a change."""
+ change = request.change
+
+ messages = list(change.message_set.order('date'))
+ patchsets = list(change.patchset_set.order('id'))
+ if not patchsets:
+ return HttpResponse('No patchset available.')
+
+ last_patchset = patchsets[-1]
+ _prepare_show_patchset(request.user, last_patchset)
+
+ if last_patchset.patches:
+ first_patch = last_patchset.patches[0]
+ else:
+ first_patch = None
+
+ depends_on = [ r.patchset.change
+ for r in last_patchset.revision.get_ancestors()
+ if r.patchset ]
+ needed_by = [ r.patchset.change
+ for r in last_patchset.revision.get_children()
+ if r.patchset ]
+
+ # approvals
+ review_status = change.get_review_status()
+ reviewer_status = models.Change.get_reviewer_status(review_status)
+ ready_to_submit = project.ready_to_submit(
+ change.dest_branch,
+ change.owner,
+ reviewer_status,
+ last_patchset.filenames)
+
+ # if the owner can lgtm or verify, show her too
+ author_status = {
+ 'lgtm': 'lgtm' if ready_to_submit['owner_auto_lgtm'] else 'abstain',
+ 'verified': ready_to_submit['owner_auto_verify']
+ }
+
+ show_dependencies = len(needed_by) > 0
+ for c in depends_on:
+ if not c.closed:
+ show_dependencies = True
+ if change.closed:
+ show_dependencies = False
+
+ can_submit = ready_to_submit['can_submit']
+ if not last_patchset.complete:
+ can_submit = False
+
+ real_approvers = ready_to_submit['real_approvers']
+ real_deniers = ready_to_submit['real_deniers']
+ real_verifiers = ready_to_submit['real_verifiers']
+
+ real_review_status = [
+ _map_status(rs, real_approvers, real_deniers, real_verifiers)
+ for rs in review_status]
+
+ # If the change isn't ready to submit, don't bother with this because
+ # they can't submit it anyway.
+ if can_submit:
+ user_can_submit = models.AccountGroup.is_user_submitter(request.user)
+ else:
+ user_can_submit = False
+
+ show_submit_button = ((not change.is_submitted)
+ and ready_to_submit
+ and user_can_submit)
+
+ _prefetch_names([change])
+ _prefetch_names(depends_on)
+ _prefetch_names(needed_by)
+ library.prefetch_names(map(lambda s: s.user, review_status))
+ return respond(request, 'change.html', {
+ 'change': change,
+ 'ready_to_submit': can_submit,
+ 'user_can_submit': user_can_submit,
+ 'show_submit_button': show_submit_button,
+ 'is_approved': ready_to_submit['approved'],
+ 'is_rejected': ready_to_submit['denied'],
+ 'is_verified': ready_to_submit['verified'],
+ 'author_status': author_status,
+ 'review_status': real_review_status,
+ 'depends_on': depends_on,
+ 'needed_by': needed_by,
+ 'show_dependencies': show_dependencies,
+ 'patchsets': patchsets,
+ 'messages': messages,
+ 'last_patchset': last_patchset,
+ 'first_patch': first_patch,
+ 'reply_url': '/%s/publish' % change.key().id(),
+ 'merge_url': '/%s/merge/%s' % (change.key().id(),
+ last_patchset.key().id()),
+ })
+
+
+@patchset_required
+def ajax_patchset(request):
+ """/<change>/ajax_patchset/<ps> - Format one patchset."""
+ change = request.change
+ patchset = request.patchset
+
+ _prepare_show_patchset(request.user, patchset)
+ return respond(request, 'patchset.html',
+ {'change' : request.change,
+ 'patchset' : request.patchset
+ })
+
+
+def revision_redirect(request, hash):
+ """/r/<hash> - Redirect to a Change for this git revision, if we can."""
+ hash = hash.lower()
+ if len(hash) < 40:
+ q = models.gql(models.RevisionId,
+ "WHERE id > :1 AND id < :2",
+ hash, hash.ljust(40, 'z'))
+ else:
+ q = models.gql(models.RevisionId, "WHERE id=:1", hash)
+ revs = q.fetch(2)
+ count = len(revs)
+
+ if count == 1:
+ rev_id = revs[0]
+ if rev_id.patchset:
+ return HttpResponseRedirect('/%s' % rev_id.patchset.change.key().id())
+ else:
+ return respond(request, 'change_revision_unknown.html', { 'hash': hash })
+
+ if count > 0:
+ return http.HttpResponseServerError("error 500: multiple matches for hash")
+
+ if len(hash) < 40:
+ q = models.gql(git_models.ReceivedBundle,
+ "WHERE contained_objects > :1"
+ " AND contained_objects < :2",
+ hash, hash.ljust(40, 'z'))
+ else:
+ q = models.gql(git_models.ReceivedBundle,
+ "WHERE contained_objects=:1",
+ hash)
+ rb = q.get()
+ if rb:
+ return respond(request, 'change_revision_uploading.html',
+ { 'hash': hash })
+ else:
+ return respond(request, 'change_revision_unknown.html',
+ { 'hash': hash },
+ status = 404)
+
+
+class EditChangeForm(BaseForm):
+ _template = 'edit.html'
+
+ reviewers = forms.CharField(required=False,
+ max_length=1000,
+ widget=forms.TextInput(attrs={'size': 60}))
+ cc = forms.CharField(required=False,
+ max_length=1000,
+ label = 'CC',
+ widget=forms.TextInput(attrs={'size': 60}))
+ closed = forms.BooleanField(required=False)
+
+ @classmethod
+ def _init(cls, change):
+ return {'initial': {'reviewers': ', '.join(change.reviewers),
+ 'cc': ', '.join(change.cc),
+ 'closed': change.closed}}
+
+ def _save(self, cd, change):
+ change.closed = cd['closed']
+ change.set_reviewers(_get_emails(self, 'reviewers'))
+ change.cc = _get_emails(self, 'cc')
+ change.put()
+
+@change_owner_required
+def edit(request):
+ """/<change>/edit - Edit a change."""
+ change = request.change
+ id = change.key().id()
+ def done():
+ return HttpResponseRedirect('/%s' % id)
+ return process_form(request, EditChangeForm, change, done,
+ {'change': change,
+ 'del_url': '/%s/delete' % id})
+
+
+@change_owner_required
+@xsrf_required
+def delete(request):
+ """/<change>/delete - Delete a change. There is no way back."""
+ change = request.change
+
+ for ps in models.PatchSet.gql('WHERE ANCESTOR IS :1', change):
+ for rev in models.RevisionId.get_for_patchset(ps):
+ rev.patchset = None
+ rev.put()
+
+ tbd = []
+ for cls in [models.Patch,
+ models.PatchSet,
+ models.PatchSetFilenames,
+ models.Comment,
+ models.Message,
+ models.ReviewStatus]:
+ tbd += cls.gql('WHERE ANCESTOR IS :1', change).fetch(50)
+ if len(tbd) > 100:
+ db.delete(tbd)
+ return respond(request, 'delete_loop.html',
+ {'change': change,
+ 'del_url': '/%s/delete' % id})
+
+ tbd += [change]
+ db.delete(tbd)
+ return HttpResponseRedirect('/mine')
+
+
+@xsrf_required
+@patchset_required
+def merge(request):
+ """/<change>/merge/<patchset> - Submit a change for merge."""
+ change = request.change
+ patchset = request.patchset
+ patchset.patches = list(patchset.patch_set.order('filename'))
+
+ if not models.AccountGroup.is_user_submitter(request.user):
+ # The button shouldn't exist if they can't do it, if they somehow
+ # managed to get here, just send them back to the change
+ # (which ought to have the button gone this time).
+ return HttpResponseRedirect('/%d' % change.key().id())
+
+ # approvals
+ reviewer_status = models.Change.get_reviewer_status(
+ change.get_review_status())
+ ready_to_submit = project.ready_to_submit(
+ change.dest_branch,
+ change.owner,
+ reviewer_status,
+ patchset.filenames)
+
+ if not ready_to_submit['can_submit']:
+ # Again, the button shouldn't have been there in this case.
+ # Just send 'em back to the change page.
+ return HttpResponseRedirect('/%d' % change.key().id())
+
+ try:
+ change.submit_merge(patchset)
+ change.put()
+ except models.InvalidSubmitMergeException, why:
+ return HttpResponseForbidden(str(why))
+ return HttpResponseRedirect('/mine')
+
+
+@patch_required
+def patch(request):
+ """/<change>/patch/<patchset>/<patch> - View a raw patch."""
+ return patch_helper(request)
+
+
+def patch_helper(request, nav_type='patch'):
+ """Returns a unified diff.
+
+ Args:
+ request: Django Request object.
+ nav_type: the navigation used in the url (i.e. patch/diff/diff2). Normally
+ the user looks at either unified or side-by-side diffs at one time, going
+ through all the files in the same mode. However, if side-by-side is not
+ available for some files, we temporarly switch them to unified view, then
+ switch them back when we can. This way they don't miss any files.
+
+ Returns:
+ Whatever respond() returns.
+ """
+ _add_next_prev(request.patchset, request.patch)
+ request.patch.nav_type = nav_type
+ parsed_lines = patching.ParsePatchToLines(request.patch.patch_lines)
+ if parsed_lines is None:
+ return HttpResponseNotFound('Can\'t parse the patch')
+ rows = engine.RenderUnifiedTableRows(request, parsed_lines)
+ return respond(request, 'patch.html',
+ {'patch': request.patch,
+ 'patchset': request.patchset,
+ 'rows': rows,
+ 'change': request.change})
+
+
+@patch_required
+def download_patch(request):
+ """/download/change<change>_<patchset>_<patch>.diff - Download patch."""
+ return HttpResponse(request.patch.patch_text, content_type='text/plain')
+
+
+def _get_context_for_user(request):
+ """Returns the context setting for a user.
+
+ The value is validated against models.CONTEXT_CHOICES.
+ If an invalid value is found, the value is overwritten with
+ models.DEFAULT_CONTEXT.
+ """
+ if request.user:
+ account = models.Account.current_user_account
+ default_context = account.default_context
+ else:
+ default_context = models.DEFAULT_CONTEXT
+ try:
+ context = int(request.GET.get("context", default_context))
+ except ValueError:
+ context = default_context
+ if context not in models.CONTEXT_CHOICES:
+ context = models.DEFAULT_CONTEXT
+ return context
+
+
+@patch_required
+def diff(request):
+ """/<change>/diff/<patchset>/<patch> - View a patch as a side-by-side diff"""
+ patchset = request.patchset
+ patch = request.patch
+
+ context = _get_context_for_user(request)
+ rows = _get_diff_table_rows(request, patch, context)
+ if isinstance(rows, HttpResponseNotFound):
+ return rows
+
+ _add_next_prev(patchset, patch)
+ return respond(request, 'diff.html',
+ {'change': request.change, 'patchset': patchset,
+ 'patch': patch, 'rows': rows,
+ 'context': context, 'context_values': models.CONTEXT_CHOICES})
+
+
+def _get_diff_table_rows(request, patch, context):
+ """Helper function that returns rendered rows for a patch"""
+ chunks = patching.ParsePatchToChunks(patch.patch_lines,
+ patch.filename)
+ if chunks is None:
+ return HttpResponseNotFound('Can\'t parse the patch')
+
+ return list(engine.RenderDiffTableRows(request, patch.old_lines,
+ chunks, patch,
+ context=context))
+
+
+@patch_required
+def diff_skipped_lines(request, id_before, id_after, where):
+ """/<change>/diff/<patchset>/<patch> - Returns a fragment of skipped lines"""
+ patchset = request.patchset
+ patch = request.patch
+
+ # TODO: allow context = None?
+ rows = _get_diff_table_rows(request, patch, 10000)
+ if isinstance(rows, HttpResponseNotFound):
+ return rows
+ return _get_skipped_lines_response(rows, id_before, id_after, where)
+
+
+def _get_skipped_lines_response(rows, id_before, id_after, where):
+ """Helper function that creates a Response object for skipped lines"""
+ response_rows = []
+ id_before = int(id_before)
+ id_after = int(id_after)
+
+ if where == "b":
+ rows.reverse()
+
+ for row in rows:
+ m = re.match('^<tr( name="hook")? id="pair-(?P<rowcount>\d+)">', row)
+ if m:
+ curr_id = int(m.groupdict().get("rowcount"))
+ if curr_id < id_before or curr_id > id_after:
+ continue
+ if where == "b" and curr_id <= id_after:
+ response_rows.append(row)
+ elif where == "t" and curr_id >= id_before:
+ response_rows.append(row)
+ if len(response_rows) >= 10:
+ break
+
+ # Create a usable structure for the JS part
+ response = []
+ dom = ElementTree.parse(StringIO('<div>%s</div>' % "".join(response_rows)))
+ for node in dom.getroot().getchildren():
+ content = "\n".join([ElementTree.tostring(x) for x in node.getchildren()])
+ response.append([node.items(), content])
+ return HttpResponse(simplejson.dumps(response))
+
+
+def _get_diff2_data(request, ps_left_id, ps_right_id, patch_id, context):
+ """Helper function that returns objects for diff2 views"""
+ ps_left = models.PatchSet.get_by_id(int(ps_left_id), parent=request.change)
+ if ps_left is None:
+ return HttpResponseNotFound('No patch set exists with that id (%s)' %
+ ps_left_id)
+ ps_left.change = request.change
+ ps_right = models.PatchSet.get_by_id(int(ps_right_id), parent=request.change)
+ if ps_right is None:
+ return HttpResponseNotFound('No patch set exists with that id (%s)' %
+ ps_right_id)
+ ps_right.change = request.change
+ patch_right = models.Patch.get_patch(ps_right, patch_id)
+ if patch_right is None:
+ return HttpResponseNotFound('No patch exists with that id (%s/%s)' %
+ (ps_right_id, patch_id))
+ patch_right.patchset = ps_right
+ # Now find the corresponding patch in ps_left
+ patch_left = models.Patch.gql('WHERE patchset = :1 AND filename = :2',
+ ps_left, patch_right.filename).get()
+ if patch_left is None:
+ return HttpResponseNotFound(
+ "Patch set %s doesn't have a patch with filename %s" %
+ (ps_left_id, patch_right.filename))
+
+ rows = engine.RenderDiff2TableRows(request,
+ patch_left.new_lines, patch_left,
+ patch_right.new_lines, patch_right,
+ context=context)
+ rows = list(rows)
+ if rows and rows[-1] is None:
+ del rows[-1]
+
+ return dict(patch_left=patch_left, ps_left=ps_left,
+ patch_right=patch_right, ps_right=ps_right,
+ rows=rows)
+
+
+@change_required
+def diff2(request, ps_left_id, ps_right_id, patch_id):
+ """/<change>/diff2/... - View the delta between two different patch sets."""
+ context = _get_context_for_user(request)
+ data = _get_diff2_data(request, ps_left_id, ps_right_id, patch_id, context)
+ if isinstance(data, HttpResponseNotFound):
+ return data
+
+ _add_next_prev(data["ps_right"], data["patch_right"])
+ return respond(request, 'diff2.html',
+ {'change': request.change,
+ 'ps_left': data["ps_left"],
+ 'patch_left': data["patch_left"],
+ 'ps_right': data["ps_right"],
+ 'patch_right': data["patch_right"],
+ 'rows': data["rows"],
+ 'patch_id': patch_id,
+ 'context': context,
+ 'context_values': models.CONTEXT_CHOICES,
+ })
+
+
+@change_required
+def diff2_skipped_lines(request, ps_left_id, ps_right_id, patch_id,
+ id_before, id_after, where):
+ """/<change>/diff2/... - Returns a fragment of skipped lines"""
+ data = _get_diff2_data(request, ps_left_id, ps_right_id, patch_id, 10000)
+ if isinstance(data, HttpResponseNotFound):
+ return data
+ return _get_skipped_lines_response(data["rows"], id_before, id_after, where)
+
+
+def _add_next_prev(patchset, patch):
+ """Helper to add .next and .prev attributes to a patch object."""
+ patch.prev = patch.next = None
+ patches = list(models.Patch.gql("WHERE patchset = :1 ORDER BY filename",
+ patchset))
+ patchset.patches = patches # Required to render the jump to select.
+ last = None
+ for p in patches:
+ if last is not None:
+ if p.filename == patch.filename:
+ patch.prev = last
+ elif last.filename == patch.filename:
+ patch.next = p
+ break
+ last = p
+
+
+def inline_draft(request):
+ """/inline_draft - Ajax handler to submit an in-line draft comment.
+
+ This wraps _inline_draft(); all exceptions are logged and cause an
+ abbreviated response indicating something went wrong.
+ """
+ if request.method != 'POST':
+ return HttpResponse("POST request required.", status=405)
+ try:
+ return _inline_draft(request)
+ except Exception, err:
+ s = ''
+ for k,v in request.POST.iteritems():
+ s += '\n%s=%s' % (k, v)
+ logging.exception('Exception in inline_draft processing:%s' % s)
+ return HttpResponse('<font color="red">'
+ 'Please report error "%s".'
+ '</font>'
+ % err.__class__.__name__)
+
+
+def _inline_draft(request):
+ """Helper to submit an in-line draft comment.
+ """
+ # Don't use @login_required; JS doesn't understand redirects.
+ if not request.user:
+ return HttpResponse('<font color="red">Not logged in</font>')
+
+ if not is_xsrf_ok(request):
+ return HttpResponse('<font color="red">'
+ 'Stale xsrf signature.<br />'
+ 'Please reload the page and try again.'
+ '</font>')
+
+ snapshot = request.POST.get('snapshot')
+ assert snapshot in ('old', 'new'), repr(snapshot)
+ left = (snapshot == 'old')
+ side = request.POST.get('side')
+ assert side in ('a', 'b'), repr(side) # Display left (a) or right (b)
+ change_id = int(request.POST['change'])
+ change = models.Change.get_by_id(change_id)
+ assert change # XXX
+ patchset_id = request.POST.get('patchset') or request.POST[side == 'a' and 'ps_left' or 'ps_right']
+ patchset = models.PatchSet.get_by_id(int(patchset_id), parent=change)
+ assert patchset # XXX
+ patch_id = request.POST.get('patch') or request.POST[side == 'a' and 'patch_left' or 'patch_right']
+ patch = models.Patch.get_patch(patchset, patch_id)
+ assert patch # XXX
+ text = request.POST.get('text')
+ lineno = int(request.POST['lineno'])
+ message_id = request.POST.get('message_id')
+ comment = None
+ if message_id:
+ comment = models.Comment.get_by_key_name(message_id, parent=patch)
+ if comment is None or not comment.draft or comment.author != request.user:
+ comment = None
+ message_id = None
+ if not message_id:
+ # Prefix with 'z' to avoid key names starting with digits.
+ message_id = 'z' + binascii.hexlify(_random_bytes(16))
+
+ if not text.rstrip():
+ if comment is not None:
+ assert comment.draft and comment.author == request.user
+ comment.delete() # Deletion
+ comment = None
+ # Re-query the comment count.
+ models.Account.current_user_account.update_drafts(change)
+ else:
+ if comment is None:
+ comment = models.Comment(
+ patch=patch,
+ key_name=message_id,
+ parent=patch)
+ comment.lineno = lineno
+ comment.left = left
+ comment.author = request.user
+ comment.text = db.Text(text)
+ comment.message_id = message_id
+ comment.put()
+ # The actual count doesn't matter, just that there's at least one.
+ models.Account.current_user_account.update_drafts(change, 1)
+
+ query = models.Comment.gql(
+ 'WHERE patch = :patch AND lineno = :lineno AND left = :left '
+ 'ORDER BY date',
+ patch=patch, lineno=lineno, left=left)
+ comments = list(c for c in query if not c.draft or c.author == request.user)
+ if comment is not None and comment.author is None:
+ # Show anonymous draft even though we don't save it
+ comments.append(comment)
+ if not comments:
+ return HttpResponse(' ')
+ for c in comments:
+ c.complete(patch)
+ return render_to_response('inline_comment.html',
+ {'inline_draft_url': '/inline_draft',
+ 'user': request.user,
+ 'patch': patch,
+ 'patchset': patchset,
+ 'change': change,
+ 'comments': comments,
+ 'lineno': lineno,
+ 'snapshot': snapshot,
+ 'side': side})
+
+
+class PublishCommentsForm(BaseForm):
+ _template = 'publish.html'
+
+ reviewers = forms.CharField(required=False,
+ max_length=1000,
+ widget=forms.TextInput(attrs={'size': 60}))
+ cc = forms.CharField(required=False,
+ max_length=1000,
+ label = 'CC',
+ widget=forms.TextInput(attrs={'size': 60}))
+ send_mail = forms.BooleanField(required=False)
+ message = forms.CharField(required=False,
+ max_length=10000,
+ widget=forms.Textarea(attrs={'cols': 60}))
+
+ lgtm = forms.CharField(label='Code review')
+ verified = forms.BooleanField(required=False,
+ label='Verified')
+
+
+
+ def __init__(self, *args, **kwargs):
+ is_initial = kwargs.pop('is_initial', False)
+ self.user_can_approve = kwargs.pop('user_can_approve', False)
+ self.user_can_verify = kwargs.pop('user_can_verify', False)
+ self.user_is_owner = kwargs.pop('user_is_owner', False)
+
+ BaseForm.__init__(self, *args, **kwargs)
+
+ if is_initial:
+ # only show the available lgtm options
+ lgtm_field = self.fields.get("lgtm")
+ if self.user_can_approve and not self.user_is_owner:
+ lgtm_field.widget = forms.RadioSelect(choices=models.LGTM_CHOICES)
+ else:
+ lgtm_field.widget = forms.RadioSelect(
+ choices=models.LIMITED_LGTM_CHOICES)
+
+ # only show verified if the user can do it
+ if not self.user_can_verify or self.user_is_owner:
+ del self.fields['verified']
+
+ @classmethod
+ def _init(cls, state):
+ request, change = state
+ user = request.user
+
+ reviewers = list(change.reviewers)
+ cc = list(change.cc)
+
+ if user != change.owner and user.email() not in reviewers:
+ reviewers.append(user.email())
+ if user.email() in cc:
+ cc.remove(user.email())
+
+ (user_can_approve,user_can_verify) = project.user_can_approve(
+ request.user, change)
+
+ # Pick the proper review / verify status
+ review = change.get_review_status(user)
+ if review:
+ lgtm = _restrict_lgtm(review.lgtm, user_can_approve)
+ verified = review.verified
+ else:
+ lgtm = 'abstain'
+ verified = False
+
+ return {'initial': {
+ 'reviewers': ', '.join(reviewers),
+ 'cc': ', '.join(cc),
+ 'send_mail': True,
+ 'lgtm': lgtm,
+ 'verified': verified
+ },
+ 'user_can_approve': user_can_approve,
+ 'user_can_verify': user_can_verify,
+ 'user_is_owner': user == change.owner,
+ 'is_initial': True,
+ }
+
+ def _save(self, cd, state):
+ request, change = state
+ user = request.user
+
+ tbd, comments = _get_draft_comments(request, change)
+ reviewers = _get_emails(self, 'reviewers')
+ cc = _get_emails(self, 'cc')
+ (self.user_can_approve,self.user_can_verify) = project.user_can_approve(
+ request.user, change)
+ lgtm = _restrict_lgtm(cd.get('lgtm', ''), self.user_can_approve)
+ verified = _restrict_verified(cd.get('verified', False),
+ self.user_can_verify)
+
+ if user != change.owner:
+ # Owners shouldn't have their own review status as it
+ # is implied that 'lgtm' and verified by them.
+ #
+ review_status = _update_review_status(change,
+ user,
+ lgtm,
+ verified)
+ tbd.append(review_status)
+
+ change.set_reviewers(reviewers)
+ change.cc = cc
+ change.update_comment_count(len(comments))
+
+ msg = _make_comment_message(request, change, lgtm, verified,
+ cd['message'],
+ comments,
+ cd['send_mail'])
+ tbd.append(msg)
+ tbd.append(change)
+
+ while tbd:
+ db.put(tbd[:50])
+ tbd = tbd[50:]
+ models.Account.current_user_account.update_drafts(change, 0)
+
+@change_required
+@login_required
+def publish(request):
+ """ /<change>/publish - Publish draft comments and send mail."""
+ change = request.change
+
+ tbd, comments = _get_draft_comments(request, change, True)
+ preview = _get_draft_details(request, comments)
+
+ def done():
+ return HttpResponseRedirect('/%s' % change.key().id())
+ return process_form(request, PublishCommentsForm, (request, change), done,
+ {'change': change,
+ 'preview' : preview})
+
+
+def _update_review_status(change, user, lgtm, verified):
+ """Creates / updates the ReviewStatus for a user, returns that object."""
+ review = change.set_review_status(user)
+ review.lgtm = lgtm
+ review.verified = verified
+ return review
+
+
+
+def _encode_safely(s):
+ """Helper to turn a unicode string into 8-bit bytes."""
+ if isinstance(s, unicode):
+ s = s.encode('utf-8')
+ return s
+
+
+def _get_draft_comments(request, change, preview=False):
+ """Helper to return objects to put() and a list of draft comments.
+
+ If preview is True, the list of objects to put() is empty to avoid changes
+ to the datastore.
+
+ Args:
+ request: Django Request object.
+ change: Change instance.
+ preview: Preview flag (default: False).
+
+ Returns:
+ 2-tuple (put_objects, comments).
+ """
+ comments = []
+ tbd = []
+ dps = dict()
+
+ # XXX Should request all drafts for this change once, now we can.
+ for patchset in change.patchset_set.order('id'):
+ ps_comments = list(models.Comment.gql(
+ 'WHERE ANCESTOR IS :1 AND author = :2 AND draft = TRUE',
+ patchset, request.user))
+ if ps_comments:
+ patches = dict((p.key(), p) for p in patchset.patch_set)
+ for p in patches.itervalues():
+ p.patchset = patchset
+ for c in ps_comments:
+ c.draft = False
+ # XXX Using internal knowledge about db package: the key for
+ # reference property foo is stored as _foo.
+ pkey = getattr(c, '_patch', None)
+ if pkey in patches:
+ patch = patches[pkey]
+ c.patch = patch
+ if pkey not in dps:
+ dps[pkey] = c.patch
+ c.patch.update_comment_count(1)
+ if not preview:
+ tbd += ps_comments
+ ps_comments.sort(key=lambda c: (c.patch.filename, not c.left,
+ c.lineno, c.date))
+ comments += ps_comments
+ if not preview:
+ tbd += dps.values()
+ return tbd, comments
+
+FILE_LINE = '======================================================================'
+COMMENT_LINE = '------------------------------'
+
+def _get_draft_details(request, comments):
+ """Helper to display comments with context in the email message."""
+ last_key = None
+ output = []
+ linecache = {} # Maps (c.patch.filename, c.left) to list of lines
+ for c in comments:
+ if (c.patch.filename, c.left) != last_key:
+ if not last_key is None:
+ output.append('%s\n' % FILE_LINE)
+ url = request.build_absolute_uri('/%d/diff/%d/%s' %
+ (request.change.key().id(),
+ c.patch.patchset.key().id(),
+ c.patch.id))
+ output.append('\n%s\n%s\nFile %s:' % (FILE_LINE, url, c.patch.filename))
+ last_key = (c.patch.filename, c.left)
+ patch = c.patch
+ if c.left:
+ linecache[last_key] = patch.old_lines
+ else:
+ linecache[last_key] = patch.new_lines
+ file_lines = linecache.get(last_key, ())
+ context = ''
+ if 1 <= c.lineno <= len(file_lines):
+ context = file_lines[c.lineno - 1].strip()
+ url = request.build_absolute_uri('/%d/diff/%d/%s#%scode%d' %
+ (request.change.key().id(),
+ c.patch.patchset.key().id(),
+ c.patch.id,
+ c.left and "old" or "new",
+ c.lineno))
+ output.append('%s\nLine %d: %s\n%s' % (COMMENT_LINE, c.lineno,
+ context, c.text.rstrip()))
+ if not last_key is None:
+ output.append('%s\n' % FILE_LINE)
+ return '\n'.join(output)
+
+def _make_comment_message(request, change, lgtm, verified, message,
+ comments=None, send_mail=False):
+ """Helper to create a Message instance and optionally send an email."""
+ # Decide who should receive mail
+ my_email = db.Email(request.user.email())
+ to = [db.Email(change.owner.email())] + change.reviewers
+ cc = change.cc[:]
+ reply_to = to + cc
+ if my_email in to and len(to) > 1: # send_mail() wants a non-empty to list
+ to.remove(my_email)
+ if my_email in cc:
+ cc.remove(my_email)
+ subject = email.make_change_subject(change)
+ if comments:
+ details = _get_draft_details(request, comments)
+ else:
+ details = ''
+ prefix = ''
+ if lgtm:
+ prefix = prefix + [y for (x,y) in models.LGTM_CHOICES
+ if x == lgtm][0] + '\n'
+ if verified:
+ prefix = prefix + 'Verified.\n'
+ if prefix:
+ prefix = prefix + '\n'
+ message = message.replace('\r\n', '\n')
+ message = prefix + message
+ text = ((message.strip() + '\n\n' + details.strip())).strip()
+ msg = models.Message(change=change,
+ subject=subject,
+ sender=my_email,
+ recipients=reply_to,
+ text=db.Text(text),
+ parent=change)
+
+ if send_mail:
+ to_users = set([change.owner] + change.reviewers + cc)
+ template_args = {
+ 'message': message,
+ 'details': details,
+ }
+ email.send_change_message(request, change,
+ 'mails/comment.txt', template_args)
+
+ return msg
+
+
+@xsrf_required
+@posted_change_required
+def star(request):
+ account = models.Account.current_user_account
+ if account.stars is None:
+ account.stars = []
+ id = request.change.key().id()
+ if id not in account.stars:
+ account.stars.append(id)
+ account.put()
+ return respond(request, 'change_star.html', {'change': request.change})
+
+
+@xsrf_required
+@posted_change_required
+def unstar(request):
+ account = models.Account.current_user_account
+ if account.stars is None:
+ account.stars = []
+ id = request.change.key().id()
+ if id in account.stars:
+ account.stars[:] = [i for i in account.stars if i != id]
+ account.put()
+ return respond(request, 'change_star.html', {'change': request.change})
+
+
+@gae_admin_required
+def download_bundle(request, bundle_id, segment_id):
+ """/download/bundle(\d+)_(\d+) - get a bundle segment"""
+ rb = git_models.ReceivedBundle.get_by_id(int(bundle_id))
+ if not rb:
+ return HttpResponseNotFound('No bundle exists with that id (%s)' % bundle_id)
+ seg = rb.get_segment(int(segment_id))
+ if not seg:
+ return HttpResponseNotFound('No segment %s in bundle %s' % (segment_id,bundle_id))
+ return HttpResponse(seg.bundle_data,
+ content_type='application/x-git-bundle-segment')
+
+
+### Administration ###
+
+@project_owner_or_admin_required
+def admin(request):
+ """/admin - user & other settings"""
+ return respond(request, 'admin.html', {})
+
+@gae_admin_required
+def admin_settings(request):
+ settings = models.Settings.get_settings()
+ return respond(request, 'admin_settings.html', {
+ 'settings': settings,
+ 'from_email_test_xsrf': xsrf_for('/admin/settings/from_email_test')
+ })
+
+class AdminSettingsAnalyticsForm(BaseForm):
+ _template = 'admin_settings_analytics.html'
+
+ analytics = forms.CharField(required=False, max_length=20,
+ widget=forms.TextInput(attrs={'size': 20}))
+
+ @classmethod
+ def _init(cls, state):
+ settings = models.Settings.get_settings()
+ return {'initial': {
+ 'analytics': settings.analytics,
+ }
+ }
+
+ def _save(self, cd, change):
+ settings = models.Settings.get_settings()
+ settings.analytics = cd['analytics']
+ settings.put()
+
+@gae_admin_required
+def admin_settings_analytics(request):
+ def done():
+ return HttpResponseRedirect('/admin/settings')
+ return process_form(request, AdminSettingsAnalyticsForm, None, done)
+
+class AdminSettingsFromEmailForm(BaseForm):
+ _template = 'admin_settings_from_email.html'
+
+ from_email = forms.CharField(required=False,
+ max_length=60,
+ widget=forms.TextInput(attrs={'size': 60}))
+
+ @classmethod
+ def _init(cls, state):
+ settings = models.Settings.get_settings()
+ return {'initial': {
+ 'from_email': settings.from_email,
+ }
+ }
+
+ def _save(self, cd, change):
+ settings = models.Settings.get_settings()
+ settings.from_email = cd['from_email']
+ settings.put()
+
+@gae_admin_required
+def admin_settings_from_email(request):
+ def done():
+ return HttpResponseRedirect('/admin/settings')
+ return process_form(request, AdminSettingsFromEmailForm, None, done)
+
+@gae_admin_required
+def admin_settings_from_email_test(request):
+ if request.method == 'POST':
+ if is_xsrf_ok(request, xsrf=request.POST.get('xsrf')):
+ address = email.get_default_sender()
+ try:
+ mail.check_email_valid(address, 'blah')
+ email.send(None, [models.Account.current_user_account], 'test',
+ 'mails/from_email_test.txt', None)
+ status = 'email sent'
+ except mail.InvalidEmailError:
+ status = 'invalid email address'
+ return respond(request, 'admin_settings_from_email_test.html', {
+ 'status': status,
+ 'address': address
+ })
+ return HttpResponse('<font color="red">Ich don\'t think so!</font>')
+
+
+
+### User Profiles ###
+
+def validate_real_name(real_name):
+ """Returns None if real_name is fine and an error message otherwise."""
+ if not real_name:
+ return 'Name cannot be empty.'
+ elif real_name == 'me':
+ return 'Of course, you are what you are. But \'me\' is for everyone.'
+ else:
+ return None
+
+
+@user_key_required
+def user_popup(request):
+ """/user_popup - Pop up to show the user info."""
+ try:
+ user = request.user_to_show
+ def gen():
+ account = models.Account.get_account_for_user(user)
+ return render_to_response('user_popup.html', {'account': account})
+ return MemCacheKey('user_popup:' + user.email(), 300).get(gen)
+ except Exception, err:
+ logging.exception('Exception in user_popup processing:')
+ return HttpResponse(
+ '<font color="red">Error: %s; please report!</font>'
+ % err.__class__.__name__)
+
+
+class AdminDataStoreDeleteForm(BaseForm):
+ _template = 'admin_datastore_delete.html'
+
+ really = forms.CharField(required=True)
+
+ def _save(self, cd, request):
+ if cd['really'] != 'DELETE EVERYTHING':
+ return self
+
+ max_cnt = 50
+ rm = []
+ specials = []
+ for cls in [models.ApprovalRight,
+ models.Project,
+ models.Branch,
+ models.RevisionId,
+ models.BuildAttempt,
+ models.Change,
+ models.PatchSetFilenames,
+ models.PatchSet,
+ models.Message,
+ models.DeltaContent,
+ models.Patch,
+ models.Comment,
+ models.Bucket,
+ models.ReviewStatus,
+ models.Account,
+ models.AccountGroup,
+ git_models.ReceivedBundleSegment,
+ git_models.ReceivedBundle,
+ ]:
+ all = cls.all().fetch(max_cnt)
+ if cls == models.Account:
+ for a in all:
+ if a.key() == request.account.key():
+ specials.append(a)
+ else:
+ rm.append(a)
+ elif cls == models.AccountGroup:
+ for a in all:
+ if a.is_auto_group:
+ specials.append(a)
+ else:
+ rm.append(a)
+ else:
+ rm.extend(all)
+ if len(rm) >= max_cnt:
+ break
+
+ if rm:
+ # Delete this block of data and continue via
+ # a JavaScript reload in the browser.
+ #
+ db.delete(rm)
+ return self
+
+ # We are done with the bulk of the content, but we need
+ # to clean up a few special objects.
+ #
+ library._user_cache.clear()
+ models.Settings._Key.clear()
+
+ specials.extend(models.Settings.all().fetch(100))
+ db.delete(specials)
+ return None
+
+@gae_admin_required
+def admin_datastore_delete(request):
+ def done():
+ return HttpResponseRedirect('/')
+ return process_form(request, AdminDataStoreDeleteForm, request, done)
+
+
+class AdminDataStoreUpgradeForm(BaseForm):
+ _template = 'admin_datastore_upgrade.html'
+
+ really = forms.CharField(required=True)
+
+ def _save(self, cd, request):
+ if cd['really'] != 'UPGRADE':
+ return self
+ return None
+
+@gae_admin_required
+def admin_datastore_upgrade(request):
+ def done():
+ return HttpResponseRedirect('/')
+ return process_form(request, AdminDataStoreUpgradeForm, request, done)