diff options
Diffstat (limited to 'webapp/codereview/views.py')
-rw-r--r-- | webapp/codereview/views.py | 1541 |
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) |