blob: c4eba3fa45c8a016ecbabb0b42818360965987d0 [file] [log] [blame]
# 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)