mirror of https://github.com/NaN-tic/rietveld.git
4141 lines
142 KiB
Python
4141 lines
142 KiB
Python
# 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 Rietveld."""
|
|
|
|
|
|
import binascii
|
|
import datetime
|
|
import email # see incoming_mail()
|
|
import email.utils
|
|
import logging
|
|
import md5
|
|
import mimetypes
|
|
import os
|
|
import random
|
|
import re
|
|
import urllib
|
|
from cStringIO import StringIO
|
|
from xml.etree import ElementTree
|
|
|
|
from google.appengine.api import mail
|
|
from google.appengine.api import memcache
|
|
from google.appengine.api import taskqueue
|
|
from google.appengine.api import users
|
|
from google.appengine.api import urlfetch
|
|
from google.appengine.api import xmpp
|
|
from google.appengine.ext import db
|
|
from google.appengine.runtime import DeadlineExceededError
|
|
from google.appengine.runtime import apiproxy_errors
|
|
|
|
from django import forms
|
|
# Import settings as django_settings to avoid name conflict with settings().
|
|
from django.conf import settings as django_settings
|
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
from django.shortcuts import render_to_response
|
|
import django.template
|
|
from django.template import RequestContext
|
|
from django.utils import encoding
|
|
from django.utils import simplejson
|
|
from django.utils.safestring import mark_safe
|
|
from django.core.urlresolvers import reverse
|
|
|
|
from codereview import engine
|
|
from codereview import library
|
|
from codereview import models
|
|
from codereview import patching
|
|
from codereview import utils
|
|
from codereview.exceptions import FetchError
|
|
|
|
|
|
# Add our own custom template tags library.
|
|
django.template.add_to_builtins('codereview.library')
|
|
|
|
|
|
### Constants ###
|
|
|
|
|
|
IS_DEV = os.environ['SERVER_SOFTWARE'].startswith('Dev') # Development server
|
|
# Maximum forms fields length
|
|
MAX_SUBJECT = 100
|
|
MAX_DESCRIPTION = 10000
|
|
MAX_URL = 2083
|
|
MAX_REVIEWERS = 1000
|
|
MAX_CC = 2000
|
|
MAX_MESSAGE = 10000
|
|
MAX_FILENAME = 255
|
|
MAX_DB_KEY_LENGTH = 1000
|
|
|
|
|
|
### Form classes ###
|
|
|
|
|
|
class AccountInput(forms.TextInput):
|
|
# Associates the necessary css/js files for the control. See
|
|
# http://docs.djangoproject.com/en/dev/topics/forms/media/.
|
|
#
|
|
# Don't forget to place {{formname.media}} into html header
|
|
# when using this html control.
|
|
class Media:
|
|
css = {
|
|
'all': ('autocomplete/jquery.autocomplete.css',)
|
|
}
|
|
js = (
|
|
'autocomplete/lib/jquery.js',
|
|
'autocomplete/lib/jquery.bgiframe.min.js',
|
|
'autocomplete/lib/jquery.ajaxQueue.js',
|
|
'autocomplete/jquery.autocomplete.js'
|
|
)
|
|
|
|
def render(self, name, value, attrs=None):
|
|
output = super(AccountInput, self).render(name, value, attrs)
|
|
if models.Account.current_user_account is not None:
|
|
# TODO(anatoli): move this into .js media for this form
|
|
data = {'name': name, 'url': reverse(account),
|
|
'multiple': 'true'}
|
|
if self.attrs.get('multiple', True) == False:
|
|
data['multiple'] = 'false'
|
|
output += mark_safe(u'''
|
|
<script type="text/javascript">
|
|
jQuery("#id_%(name)s").autocomplete("%(url)s", {
|
|
max: 10,
|
|
highlight: false,
|
|
multiple: %(multiple)s,
|
|
multipleSeparator: ", ",
|
|
scroll: true,
|
|
scrollHeight: 300,
|
|
matchContains: true,
|
|
formatResult : function(row) {
|
|
return row[0].replace(/ .+/gi, '');
|
|
}
|
|
});
|
|
</script>''' % data)
|
|
return output
|
|
|
|
|
|
class IssueBaseForm(forms.Form):
|
|
|
|
subject = forms.CharField(max_length=MAX_SUBJECT,
|
|
widget=forms.TextInput(attrs={'size': 60}))
|
|
description = forms.CharField(required=False,
|
|
max_length=MAX_DESCRIPTION,
|
|
widget=forms.Textarea(attrs={'cols': 60}))
|
|
branch = forms.ChoiceField(required=False, label='Base URL')
|
|
base = forms.CharField(required=False,
|
|
max_length=MAX_URL,
|
|
widget=forms.TextInput(attrs={'size': 60}))
|
|
reviewers = forms.CharField(required=False,
|
|
max_length=MAX_REVIEWERS,
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
cc = forms.CharField(required=False,
|
|
max_length=MAX_CC,
|
|
label = 'CC',
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
private = forms.BooleanField(required=False, initial=False)
|
|
|
|
def set_branch_choices(self, base=None):
|
|
branches = models.Branch.all()
|
|
bound_field = self['branch']
|
|
choices = []
|
|
default = None
|
|
for b in branches:
|
|
if not b.repo_name:
|
|
b.repo_name = b.repo.name
|
|
b.put()
|
|
pair = (b.key(), '%s - %s - %s' % (b.repo_name, b.category, b.name))
|
|
choices.append(pair)
|
|
if default is None and (base is None or b.url == base):
|
|
default = b.key()
|
|
choices.sort(key=lambda pair: pair[1].lower())
|
|
choices.insert(0, ('', '[See Base]'))
|
|
bound_field.field.choices = choices
|
|
if default is not None:
|
|
self.initial['branch'] = default
|
|
|
|
def get_base(self):
|
|
base = self.cleaned_data.get('base')
|
|
if not base:
|
|
key = self.cleaned_data['branch']
|
|
if key:
|
|
branch = models.Branch.get(key)
|
|
if branch is not None:
|
|
base = branch.url
|
|
if not base:
|
|
self.errors['base'] = ['You must specify a base']
|
|
return base or None
|
|
|
|
|
|
class NewForm(IssueBaseForm):
|
|
|
|
data = forms.FileField(required=False)
|
|
url = forms.URLField(required=False,
|
|
max_length=MAX_URL,
|
|
widget=forms.TextInput(attrs={'size': 60}))
|
|
send_mail = forms.BooleanField(required=False, initial=True)
|
|
|
|
|
|
class AddForm(forms.Form):
|
|
|
|
message = forms.CharField(max_length=MAX_SUBJECT,
|
|
widget=forms.TextInput(attrs={'size': 60}))
|
|
data = forms.FileField(required=False)
|
|
url = forms.URLField(required=False,
|
|
max_length=MAX_URL,
|
|
widget=forms.TextInput(attrs={'size': 60}))
|
|
reviewers = forms.CharField(max_length=MAX_REVIEWERS, required=False,
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
send_mail = forms.BooleanField(required=False, initial=True)
|
|
|
|
|
|
class UploadForm(forms.Form):
|
|
|
|
subject = forms.CharField(max_length=MAX_SUBJECT)
|
|
description = forms.CharField(max_length=MAX_DESCRIPTION, required=False)
|
|
content_upload = forms.BooleanField(required=False)
|
|
separate_patches = forms.BooleanField(required=False)
|
|
base = forms.CharField(max_length=MAX_URL, required=False)
|
|
data = forms.FileField(required=False)
|
|
issue = forms.IntegerField(required=False)
|
|
reviewers = forms.CharField(max_length=MAX_REVIEWERS, required=False)
|
|
cc = forms.CharField(max_length=MAX_CC, required=False)
|
|
private = forms.BooleanField(required=False, initial=False)
|
|
send_mail = forms.BooleanField(required=False)
|
|
base_hashes = forms.CharField(required=False)
|
|
repo_guid = forms.CharField(required=False, max_length=MAX_URL)
|
|
|
|
def clean_base(self):
|
|
base = self.cleaned_data.get('base')
|
|
if not base and not self.cleaned_data.get('content_upload', False):
|
|
raise forms.ValidationError, 'Base URL is required.'
|
|
return self.cleaned_data.get('base')
|
|
|
|
def get_base(self):
|
|
return self.cleaned_data.get('base')
|
|
|
|
|
|
class UploadContentForm(forms.Form):
|
|
filename = forms.CharField(max_length=MAX_FILENAME)
|
|
status = forms.CharField(required=False, max_length=20)
|
|
checksum = forms.CharField(max_length=32)
|
|
file_too_large = forms.BooleanField(required=False)
|
|
is_binary = forms.BooleanField(required=False)
|
|
is_current = forms.BooleanField(required=False)
|
|
|
|
def clean(self):
|
|
# Check presence of 'data'. We cannot use FileField because
|
|
# it disallows empty files.
|
|
super(UploadContentForm, self).clean()
|
|
if not self.files and 'data' not in self.files:
|
|
raise forms.ValidationError, 'No content uploaded.'
|
|
return self.cleaned_data
|
|
|
|
def get_uploaded_content(self):
|
|
return self.files['data'].read()
|
|
|
|
|
|
class UploadPatchForm(forms.Form):
|
|
filename = forms.CharField(max_length=MAX_FILENAME)
|
|
content_upload = forms.BooleanField(required=False)
|
|
|
|
def get_uploaded_patch(self):
|
|
return self.files['data'].read()
|
|
|
|
|
|
class EditForm(IssueBaseForm):
|
|
|
|
closed = forms.BooleanField(required=False)
|
|
|
|
|
|
class EditLocalBaseForm(forms.Form):
|
|
subject = forms.CharField(max_length=MAX_SUBJECT,
|
|
widget=forms.TextInput(attrs={'size': 60}))
|
|
description = forms.CharField(required=False,
|
|
max_length=MAX_DESCRIPTION,
|
|
widget=forms.Textarea(attrs={'cols': 60}))
|
|
reviewers = forms.CharField(required=False,
|
|
max_length=MAX_REVIEWERS,
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
cc = forms.CharField(required=False,
|
|
max_length=MAX_CC,
|
|
label = 'CC',
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
private = forms.BooleanField(required=False, initial=False)
|
|
closed = forms.BooleanField(required=False)
|
|
|
|
def get_base(self):
|
|
return None
|
|
|
|
|
|
class RepoForm(forms.Form):
|
|
name = forms.CharField()
|
|
url = forms.URLField()
|
|
guid = forms.CharField(required=False)
|
|
|
|
|
|
class BranchForm(forms.Form):
|
|
category = forms.CharField(
|
|
widget=forms.Select(choices=[(ch, ch)
|
|
for ch in models.Branch.category.choices]))
|
|
name = forms.CharField()
|
|
url = forms.URLField()
|
|
|
|
|
|
class PublishForm(forms.Form):
|
|
|
|
subject = forms.CharField(max_length=MAX_SUBJECT,
|
|
widget=forms.TextInput(attrs={'size': 60}))
|
|
reviewers = forms.CharField(required=False,
|
|
max_length=MAX_REVIEWERS,
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
cc = forms.CharField(required=False,
|
|
max_length=MAX_CC,
|
|
label = 'CC',
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
send_mail = forms.BooleanField(required=False)
|
|
message = forms.CharField(required=False,
|
|
max_length=MAX_MESSAGE,
|
|
widget=forms.Textarea(attrs={'cols': 60}))
|
|
message_only = forms.BooleanField(required=False,
|
|
widget=forms.HiddenInput())
|
|
no_redirect = forms.BooleanField(required=False,
|
|
widget=forms.HiddenInput())
|
|
in_reply_to = forms.CharField(required=False,
|
|
max_length=MAX_DB_KEY_LENGTH,
|
|
widget=forms.HiddenInput())
|
|
|
|
|
|
class MiniPublishForm(forms.Form):
|
|
|
|
reviewers = forms.CharField(required=False,
|
|
max_length=MAX_REVIEWERS,
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
cc = forms.CharField(required=False,
|
|
max_length=MAX_CC,
|
|
label = 'CC',
|
|
widget=AccountInput(attrs={'size': 60}))
|
|
send_mail = forms.BooleanField(required=False)
|
|
message = forms.CharField(required=False,
|
|
max_length=MAX_MESSAGE,
|
|
widget=forms.Textarea(attrs={'cols': 60}))
|
|
message_only = forms.BooleanField(required=False,
|
|
widget=forms.HiddenInput())
|
|
no_redirect = forms.BooleanField(required=False,
|
|
widget=forms.HiddenInput())
|
|
|
|
|
|
class BlockForm(forms.Form):
|
|
blocked = forms.BooleanField(
|
|
required=False,
|
|
help_text='Should this user be blocked')
|
|
|
|
|
|
FORM_CONTEXT_VALUES = [(x, '%d lines' % x) for x in models.CONTEXT_CHOICES]
|
|
FORM_CONTEXT_VALUES.append(('', 'Whole file'))
|
|
|
|
|
|
class SettingsForm(forms.Form):
|
|
|
|
nickname = forms.CharField(max_length=30)
|
|
context = forms.IntegerField(
|
|
widget=forms.Select(choices=FORM_CONTEXT_VALUES),
|
|
required=False,
|
|
label='Context')
|
|
column_width = forms.IntegerField(
|
|
initial=django_settings.DEFAULT_COLUMN_WIDTH,
|
|
min_value=django_settings.MIN_COLUMN_WIDTH,
|
|
max_value=django_settings.MAX_COLUMN_WIDTH)
|
|
notify_by_email = forms.BooleanField(required=False,
|
|
widget=forms.HiddenInput())
|
|
notify_by_chat = forms.BooleanField(
|
|
required=False,
|
|
help_text='You must accept the invite for this to work.')
|
|
|
|
def clean_nickname(self):
|
|
nickname = self.cleaned_data.get('nickname')
|
|
# Check for allowed characters
|
|
match = re.match(r'[\w\.\-_\(\) ]+$', nickname, re.UNICODE|re.IGNORECASE)
|
|
if not match:
|
|
raise forms.ValidationError('Allowed characters are letters, digits, '
|
|
'".-_()" and spaces.')
|
|
# Check for sane whitespaces
|
|
if re.search(r'\s{2,}', nickname):
|
|
raise forms.ValidationError('Use single spaces between words.')
|
|
if len(nickname) != len(nickname.strip()):
|
|
raise forms.ValidationError('Leading and trailing whitespaces are '
|
|
'not allowed.')
|
|
|
|
if nickname.lower() == 'me':
|
|
raise forms.ValidationError('Choose a different nickname.')
|
|
|
|
# Look for existing nicknames
|
|
accounts = list(models.Account.gql('WHERE lower_nickname = :1',
|
|
nickname.lower()))
|
|
for account in accounts:
|
|
if account.key() == models.Account.current_user_account.key():
|
|
continue
|
|
raise forms.ValidationError('This nickname is already in use.')
|
|
|
|
return nickname
|
|
|
|
|
|
class MigrateEntitiesForm(forms.Form):
|
|
|
|
account = forms.CharField(label='Your previous email address')
|
|
_user = None
|
|
|
|
def set_user(self, user):
|
|
"""Sets the _user attribute.
|
|
|
|
A user object is needed for validation. This method has to be
|
|
called before is_valid() is called to allow us to validate if a
|
|
email address given in account belongs to the same user.
|
|
"""
|
|
self._user = user
|
|
|
|
def clean_account(self):
|
|
"""Verifies that an account with this emails exists and returns it.
|
|
|
|
This method is executed by Django when Form.is_valid() is called.
|
|
"""
|
|
if self._user is None:
|
|
raise forms.ValidationError('No user given.')
|
|
account = models.Account.get_account_for_email(self.cleaned_data['account'])
|
|
if account is None:
|
|
raise forms.ValidationError('No such email.')
|
|
if account.user.email() == self._user.email():
|
|
raise forms.ValidationError(
|
|
'Nothing to do. This is your current email address.')
|
|
if account.user.user_id() != self._user.user_id():
|
|
raise forms.ValidationError(
|
|
'This email address isn\'t related to your account.')
|
|
return account
|
|
|
|
|
|
ORDER_CHOICES = (
|
|
'__key__',
|
|
'owner',
|
|
'created',
|
|
'modified',
|
|
)
|
|
|
|
class SearchForm(forms.Form):
|
|
|
|
format = forms.ChoiceField(
|
|
required=False,
|
|
choices=(
|
|
('html', 'html'),
|
|
('json', 'json')),
|
|
widget=forms.HiddenInput(attrs={'value': 'html'}))
|
|
keys_only = forms.BooleanField(
|
|
required=False,
|
|
widget=forms.HiddenInput(attrs={'value': 'False'}))
|
|
with_messages = forms.BooleanField(
|
|
required=False,
|
|
widget=forms.HiddenInput(attrs={'value': 'False'}))
|
|
cursor = forms.CharField(
|
|
required=False,
|
|
widget=forms.HiddenInput(attrs={'value': ''}))
|
|
limit = forms.IntegerField(
|
|
required=False,
|
|
min_value=1,
|
|
max_value=1000,
|
|
widget=forms.HiddenInput(attrs={'value': '30'}))
|
|
closed = forms.NullBooleanField(required=False)
|
|
owner = forms.CharField(required=False,
|
|
max_length=MAX_REVIEWERS,
|
|
widget=AccountInput(attrs={'size': 60,
|
|
'multiple': False}))
|
|
reviewer = forms.CharField(required=False,
|
|
max_length=MAX_REVIEWERS,
|
|
widget=AccountInput(attrs={'size': 60,
|
|
'multiple': False}))
|
|
repo_guid = forms.CharField(required=False, max_length=MAX_URL,
|
|
label="Repository ID")
|
|
base = forms.CharField(required=False, max_length=MAX_URL)
|
|
private = forms.NullBooleanField(required=False)
|
|
created_before = forms.DateTimeField(required=False, label='Created before')
|
|
created_after = forms.DateTimeField(
|
|
required=False, label='Created on or after')
|
|
modified_before = forms.DateTimeField(required=False, label='Modified before')
|
|
modified_after = forms.DateTimeField(
|
|
required=False, label='Modified on or after')
|
|
order = forms.ChoiceField(
|
|
required=False, help_text='Order: Name of one of the datastore keys',
|
|
choices=sum(
|
|
([(x, x), ('-' + x, '-' + x)] for x in ORDER_CHOICES),
|
|
[('', '(default)')]))
|
|
|
|
def _clean_accounts(self, key):
|
|
"""Cleans up autocomplete field.
|
|
|
|
The input is validated to be zero or one name/email and it's
|
|
validated that the users exists.
|
|
|
|
Args:
|
|
key: the field name.
|
|
|
|
Returns an User instance or raises ValidationError.
|
|
"""
|
|
accounts = filter(None,
|
|
(x.strip()
|
|
for x in self.cleaned_data.get(key, '').split(',')))
|
|
if len(accounts) > 1:
|
|
raise forms.ValidationError('Only one user name is allowed.')
|
|
elif not accounts:
|
|
return None
|
|
account = accounts[0]
|
|
if '@' in account:
|
|
acct = models.Account.get_account_for_email(account)
|
|
else:
|
|
acct = models.Account.get_account_for_nickname(account)
|
|
if not acct:
|
|
raise forms.ValidationError('Unknown user')
|
|
return acct.user
|
|
|
|
def clean_owner(self):
|
|
return self._clean_accounts('owner')
|
|
|
|
def clean_reviewer(self):
|
|
user = self._clean_accounts('reviewer')
|
|
if user:
|
|
return user.email()
|
|
|
|
|
|
### Exceptions ###
|
|
|
|
|
|
class InvalidIncomingEmailError(Exception):
|
|
"""Exception raised by incoming mail handler when a problem occurs."""
|
|
|
|
|
|
### Helper functions ###
|
|
|
|
|
|
# Counter displayed (by respond()) below) on every page showing how
|
|
# many requests the current incarnation has handled, not counting
|
|
# redirects. Rendered by templates/base.html.
|
|
counter = 0
|
|
|
|
|
|
def respond(request, template, params=None):
|
|
"""Helper to render a response, passing standard stuff to the response.
|
|
|
|
Args:
|
|
request: The request object.
|
|
template: The template name; '.html' is appended automatically.
|
|
params: A dict giving the template parameters; modified in-place.
|
|
|
|
Returns:
|
|
Whatever render_to_response(template, params) returns.
|
|
|
|
Raises:
|
|
Whatever render_to_response(template, params) raises.
|
|
"""
|
|
global counter
|
|
counter += 1
|
|
if params is None:
|
|
params = {}
|
|
must_choose_nickname = False
|
|
uploadpy_hint = False
|
|
if request.user is not None:
|
|
account = models.Account.current_user_account
|
|
must_choose_nickname = not account.user_has_selected_nickname()
|
|
uploadpy_hint = account.uploadpy_hint
|
|
params['request'] = request
|
|
params['counter'] = counter
|
|
params['user'] = request.user
|
|
params['is_admin'] = request.user_is_admin
|
|
params['is_dev'] = IS_DEV
|
|
params['media_url'] = django_settings.MEDIA_URL
|
|
params['special_banner'] = getattr(django_settings, 'SPECIAL_BANNER', None)
|
|
full_path = request.get_full_path().encode('utf-8')
|
|
if request.user is None:
|
|
params['sign_in'] = users.create_login_url(full_path)
|
|
else:
|
|
params['sign_out'] = users.create_logout_url(full_path)
|
|
account = models.Account.current_user_account
|
|
if account is not None:
|
|
params['xsrf_token'] = account.get_xsrf_token()
|
|
params['must_choose_nickname'] = must_choose_nickname
|
|
params['uploadpy_hint'] = uploadpy_hint
|
|
params['rietveld_revision'] = django_settings.RIETVELD_REVISION
|
|
try:
|
|
return render_to_response(template, params,
|
|
context_instance=RequestContext(request))
|
|
finally:
|
|
library.user_cache.clear() # don't want this sticking around
|
|
|
|
|
|
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))))
|
|
|
|
|
|
def _clean_int(value, default, min_value=None, max_value=None):
|
|
"""Helper to cast value to int and to clip it to min or max_value.
|
|
|
|
Args:
|
|
value: Any value (preferably something that can be casted to int).
|
|
default: Default value to be used when type casting fails.
|
|
min_value: Minimum allowed value (default: None).
|
|
max_value: Maximum allowed value (default: None).
|
|
|
|
Returns:
|
|
An integer between min_value and max_value.
|
|
"""
|
|
if not isinstance(value, (int, long)):
|
|
try:
|
|
value = int(value)
|
|
except (TypeError, ValueError):
|
|
value = default
|
|
if min_value is not None:
|
|
value = max(min_value, value)
|
|
if max_value is not None:
|
|
value = min(value, max_value)
|
|
return value
|
|
|
|
|
|
def _can_view_issue(user, issue):
|
|
if user is None:
|
|
return not issue.private
|
|
user_email = db.Email(user.email().lower())
|
|
return (not issue.private
|
|
or issue.owner == user
|
|
or user_email in issue.cc
|
|
or user_email in issue.reviewers
|
|
or user.email() in issue.collaborator_emails())
|
|
|
|
|
|
def _notify_issue(request, issue, message):
|
|
"""Try sending an XMPP (chat) message.
|
|
|
|
Args:
|
|
request: The request object.
|
|
issue: Issue whose owner, reviewers, CC are to be notified.
|
|
message: Text of message to send, e.g. 'Created'.
|
|
|
|
The current user and the issue's subject and URL are appended to the message.
|
|
|
|
Returns:
|
|
True if the message was (apparently) delivered, False if not.
|
|
"""
|
|
iid = issue.key().id()
|
|
emails = [issue.owner.email()]
|
|
if issue.reviewers:
|
|
emails.extend(issue.reviewers)
|
|
if issue.cc:
|
|
emails.extend(issue.cc)
|
|
accounts = models.Account.get_multiple_accounts_by_email(emails)
|
|
jids = []
|
|
for account in accounts.itervalues():
|
|
logging.debug('email=%r,chat=%r', account.email, account.notify_by_chat)
|
|
if account.notify_by_chat:
|
|
jids.append(account.email)
|
|
if not jids:
|
|
logging.debug('No XMPP jids to send to for issue %d', iid)
|
|
return True # Nothing to do.
|
|
jids_str = ', '.join(jids)
|
|
logging.debug('Sending XMPP for issue %d to %s', iid, jids_str)
|
|
sender = '?'
|
|
if models.Account.current_user_account:
|
|
sender = models.Account.current_user_account.nickname
|
|
elif request.user:
|
|
sender = request.user.email()
|
|
message = '%s by %s: %s\n%s' % (message,
|
|
sender,
|
|
issue.subject,
|
|
request.build_absolute_uri(
|
|
reverse(show, args=[iid])))
|
|
try:
|
|
sts = xmpp.send_message(jids, message)
|
|
except Exception, err:
|
|
logging.exception('XMPP exception %s sending for issue %d to %s',
|
|
err, iid, jids_str)
|
|
return False
|
|
else:
|
|
if sts == [xmpp.NO_ERROR] * len(jids):
|
|
logging.info('XMPP message sent for issue %d to %s', iid, jids_str)
|
|
return True
|
|
else:
|
|
logging.error('XMPP error %r sending for issue %d to %s',
|
|
sts, iid, jids_str)
|
|
return False
|
|
|
|
|
|
class HttpTextResponse(HttpResponse):
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs['content_type'] = 'text/plain; charset=utf-8'
|
|
super(HttpTextResponse, self).__init__(*args, **kwargs)
|
|
|
|
|
|
class HttpHtmlResponse(HttpResponse):
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs['content_type'] = 'text/html; charset=utf-8'
|
|
super(HttpHtmlResponse, self).__init__(*args, **kwargs)
|
|
|
|
|
|
### Decorators for request handlers ###
|
|
|
|
|
|
def post_required(func):
|
|
"""Decorator that returns an error unless request.method == 'POST'."""
|
|
|
|
def post_wrapper(request, *args, **kwds):
|
|
if request.method != 'POST':
|
|
return HttpTextResponse('This requires a POST request.', status=405)
|
|
return func(request, *args, **kwds)
|
|
|
|
return post_wrapper
|
|
|
|
|
|
def login_required(func):
|
|
"""Decorator that redirects to the login page if you're not logged in."""
|
|
|
|
def login_wrapper(request, *args, **kwds):
|
|
if request.user is None:
|
|
return HttpResponseRedirect(
|
|
users.create_login_url(request.get_full_path().encode('utf-8')))
|
|
return func(request, *args, **kwds)
|
|
|
|
return login_wrapper
|
|
|
|
|
|
def xsrf_required(func):
|
|
"""Decorator to check XSRF token.
|
|
|
|
This only checks if the method is POST; it lets other method go
|
|
through unchallenged. Apply after @login_required and (if
|
|
applicable) @post_required. This decorator is mutually exclusive
|
|
with @upload_required.
|
|
"""
|
|
|
|
def xsrf_wrapper(request, *args, **kwds):
|
|
if request.method == 'POST':
|
|
post_token = request.POST.get('xsrf_token')
|
|
if not post_token:
|
|
return HttpTextResponse('Missing XSRF token.', status=403)
|
|
account = models.Account.current_user_account
|
|
if not account:
|
|
return HttpTextResponse('Must be logged in for XSRF check.', status=403)
|
|
xsrf_token = account.get_xsrf_token()
|
|
if post_token != xsrf_token:
|
|
# Try the previous hour's token
|
|
xsrf_token = account.get_xsrf_token(-1)
|
|
if post_token != xsrf_token:
|
|
msg = [u'Invalid XSRF token.']
|
|
if request.POST:
|
|
msg.extend([u'',
|
|
u'However, this was the data posted to the server:',
|
|
u''])
|
|
for key in request.POST:
|
|
msg.append(u'%s: %s' % (key, request.POST[key]))
|
|
msg.extend([u'', u'-'*10,
|
|
u'Please reload the previous page and post again.'])
|
|
return HttpTextResponse(u'\n'.join(msg), status=403)
|
|
return func(request, *args, **kwds)
|
|
|
|
return xsrf_wrapper
|
|
|
|
|
|
def upload_required(func):
|
|
"""Decorator for POST requests from the upload.py script.
|
|
|
|
Right now this is for documentation only, but eventually we should
|
|
change this to insist on a special header that JavaScript cannot
|
|
add, to prevent XSRF attacks on these URLs. This decorator is
|
|
mutually exclusive with @xsrf_required.
|
|
"""
|
|
return func
|
|
|
|
|
|
def admin_required(func):
|
|
"""Decorator that insists that you're logged in as administratior."""
|
|
|
|
def admin_wrapper(request, *args, **kwds):
|
|
if request.user is None:
|
|
return HttpResponseRedirect(
|
|
users.create_login_url(request.get_full_path().encode('utf-8')))
|
|
if not request.user_is_admin:
|
|
return HttpTextResponse(
|
|
'You must be admin in for this function', status=403)
|
|
return func(request, *args, **kwds)
|
|
|
|
return admin_wrapper
|
|
|
|
|
|
def issue_required(func):
|
|
"""Decorator that processes the issue_id handler argument."""
|
|
|
|
def issue_wrapper(request, issue_id, *args, **kwds):
|
|
issue = models.Issue.get_by_id(int(issue_id))
|
|
if issue is None:
|
|
return HttpTextResponse(
|
|
'No issue exists with that id (%s)' % issue_id, status=404)
|
|
if issue.private:
|
|
if request.user is None:
|
|
return HttpResponseRedirect(
|
|
users.create_login_url(request.get_full_path().encode('utf-8')))
|
|
if not _can_view_issue(request.user, issue):
|
|
return HttpTextResponse(
|
|
'You do not have permission to view this issue', status=403)
|
|
request.issue = issue
|
|
return func(request, *args, **kwds)
|
|
|
|
return issue_wrapper
|
|
|
|
|
|
def user_key_required(func):
|
|
"""Decorator that processes the user handler argument."""
|
|
|
|
def user_key_wrapper(request, user_key, *args, **kwds):
|
|
user_key = urllib.unquote(user_key)
|
|
if '@' in user_key:
|
|
request.user_to_show = users.User(user_key)
|
|
else:
|
|
account = models.Account.get_account_for_nickname(user_key)
|
|
if not account:
|
|
logging.info("account not found for nickname %s" % user_key)
|
|
return HttpTextResponse(
|
|
'No user found with that key (%s)' % urllib.quote(user_key),
|
|
status=404)
|
|
request.user_to_show = account.user
|
|
return func(request, *args, **kwds)
|
|
|
|
return user_key_wrapper
|
|
|
|
|
|
def owner_required(func):
|
|
"""Decorator that insists you own the issue.
|
|
|
|
It must appear after issue_required or equivalent, like patchset_required.
|
|
"""
|
|
|
|
@login_required
|
|
def owner_wrapper(request, *args, **kwds):
|
|
if not (request.issue.owner == request.user or
|
|
request.issue.is_collaborator(request.user)):
|
|
return HttpTextResponse('You do not own this issue', status=403)
|
|
return func(request, *args, **kwds)
|
|
|
|
return owner_wrapper
|
|
|
|
|
|
def issue_owner_required(func):
|
|
"""Decorator that processes the issue_id argument and insists you own it."""
|
|
|
|
@issue_required
|
|
@owner_required
|
|
def issue_owner_wrapper(request, *args, **kwds):
|
|
return func(request, *args, **kwds)
|
|
|
|
return issue_owner_wrapper
|
|
|
|
|
|
def issue_editor_required(func):
|
|
"""Decorator that processes the issue_id argument and insists the user has
|
|
permission to edit it."""
|
|
|
|
@login_required
|
|
@issue_required
|
|
def issue_editor_wrapper(request, *args, **kwds):
|
|
if not request.issue.user_can_edit(request.user):
|
|
return HttpTextResponse(
|
|
'You do not have permission to edit this issue', status=403)
|
|
return func(request, *args, **kwds)
|
|
|
|
return issue_editor_wrapper
|
|
|
|
|
|
def patchset_required(func):
|
|
"""Decorator that processes the patchset_id argument."""
|
|
|
|
@issue_required
|
|
def patchset_wrapper(request, patchset_id, *args, **kwds):
|
|
patchset = models.PatchSet.get_by_id(int(patchset_id), parent=request.issue)
|
|
if patchset is None:
|
|
return HttpTextResponse(
|
|
'No patch set exists with that id (%s)' % patchset_id, status=404)
|
|
patchset.issue = request.issue
|
|
request.patchset = patchset
|
|
return func(request, *args, **kwds)
|
|
|
|
return patchset_wrapper
|
|
|
|
|
|
def patchset_owner_required(func):
|
|
"""Decorator that processes the patchset_id argument and insists you own the
|
|
issue."""
|
|
|
|
@patchset_required
|
|
@owner_required
|
|
def patchset_owner_wrapper(request, *args, **kwds):
|
|
return func(request, *args, **kwds)
|
|
|
|
return patchset_owner_wrapper
|
|
|
|
|
|
def patch_required(func):
|
|
"""Decorator that processes the patch_id argument."""
|
|
|
|
@patchset_required
|
|
def patch_wrapper(request, patch_id, *args, **kwds):
|
|
patch = models.Patch.get_by_id(int(patch_id), parent=request.patchset)
|
|
if patch is None:
|
|
return HttpTextResponse(
|
|
'No patch exists with that id (%s/%s)' %
|
|
(request.patchset.key().id(), patch_id),
|
|
status=404)
|
|
patch.patchset = request.patchset
|
|
request.patch = patch
|
|
return func(request, *args, **kwds)
|
|
|
|
return patch_wrapper
|
|
|
|
|
|
def patch_filename_required(func):
|
|
"""Decorator that processes the patch_id argument."""
|
|
|
|
@patchset_required
|
|
def patch_wrapper(request, patch_filename, *args, **kwds):
|
|
patch = models.Patch.gql('WHERE patchset = :1 AND filename = :2',
|
|
request.patchset, patch_filename).get()
|
|
if patch is None and patch_filename.isdigit():
|
|
# It could be an old URL which has a patch ID instead of a filename
|
|
patch = models.Patch.get_by_id(int(patch_filename),
|
|
parent=request.patchset)
|
|
if patch is None:
|
|
return respond(request, 'diff_missing.html',
|
|
{'issue': request.issue,
|
|
'patchset': request.patchset,
|
|
'patch': None,
|
|
'patchsets': request.issue.patchset_set,
|
|
'filename': patch_filename})
|
|
patch.patchset = request.patchset
|
|
request.patch = patch
|
|
return func(request, *args, **kwds)
|
|
|
|
return patch_wrapper
|
|
|
|
|
|
def image_required(func):
|
|
"""Decorator that processes the image argument.
|
|
|
|
Attributes set on the request:
|
|
content: a Content entity.
|
|
"""
|
|
|
|
@patch_required
|
|
def image_wrapper(request, image_type, *args, **kwds):
|
|
content = None
|
|
if image_type == "0":
|
|
content = request.patch.content
|
|
elif image_type == "1":
|
|
content = request.patch.patched_content
|
|
# Other values are erroneous so request.content won't be set.
|
|
if not content or not content.data:
|
|
return HttpResponseRedirect(django_settings.MEDIA_URL + "blank.jpg")
|
|
request.mime_type = mimetypes.guess_type(request.patch.filename)[0]
|
|
if not request.mime_type or not request.mime_type.startswith('image/'):
|
|
return HttpResponseRedirect(django_settings.MEDIA_URL + "blank.jpg")
|
|
request.content = content
|
|
return func(request, *args, **kwds)
|
|
|
|
return image_wrapper
|
|
|
|
|
|
def json_response(func):
|
|
"""Decorator that converts into JSON any returned value that is not an
|
|
HttpResponse. It handles `pretty` URL parameter to tune JSON response for
|
|
either performance or readability."""
|
|
|
|
def json_wrapper(request, *args, **kwds):
|
|
data = func(request, *args, **kwds)
|
|
if isinstance(data, HttpResponse):
|
|
return data
|
|
if request.REQUEST.get('pretty','0').lower() in ('1', 'true', 'on'):
|
|
data = simplejson.dumps(data, indent=' ', sort_keys=True)
|
|
else:
|
|
data = simplejson.dumps(data, separators=(',',':'))
|
|
return HttpResponse(data, content_type='application/json; charset=utf-8')
|
|
|
|
return json_wrapper
|
|
|
|
|
|
### Request handlers ###
|
|
|
|
|
|
def index(request):
|
|
"""/ - Show a list of review issues"""
|
|
if request.user is None:
|
|
return all(request, index_call=True)
|
|
else:
|
|
return mine(request)
|
|
|
|
|
|
DEFAULT_LIMIT = 20
|
|
|
|
|
|
def _url(path, **kwargs):
|
|
"""Format parameters for query string.
|
|
|
|
Args:
|
|
path: Path of URL.
|
|
kwargs: Keyword parameters are treated as values to add to the query
|
|
parameter of the URL. If empty no query parameters will be added to
|
|
path and '?' omitted from the URL.
|
|
"""
|
|
if kwargs:
|
|
encoded_parameters = urllib.urlencode(kwargs)
|
|
if path.endswith('?'):
|
|
# Trailing ? on path. Append parameters to end.
|
|
return '%s%s' % (path, encoded_parameters)
|
|
elif '?' in path:
|
|
# Append additional parameters to existing query parameters.
|
|
return '%s&%s' % (path, encoded_parameters)
|
|
else:
|
|
# Add query parameters to path with no query parameters.
|
|
return '%s?%s' % (path, encoded_parameters)
|
|
else:
|
|
return path
|
|
|
|
|
|
def _inner_paginate(request, issues, template, extra_template_params):
|
|
"""Display paginated list of issues.
|
|
|
|
Takes care of the private bit.
|
|
|
|
Args:
|
|
request: Request containing offset and limit parameters.
|
|
issues: Issues to be displayed.
|
|
template: Name of template that renders issue page.
|
|
extra_template_params: Dictionary of extra parameters to pass to page
|
|
rendering.
|
|
|
|
Returns:
|
|
Response for sending back to browser.
|
|
"""
|
|
visible_issues = [i for i in issues if _can_view_issue(request.user, i)]
|
|
_optimize_draft_counts(visible_issues)
|
|
_load_users_for_issues(visible_issues)
|
|
params = {
|
|
'issues': visible_issues,
|
|
'limit': None,
|
|
'newest': None,
|
|
'prev': None,
|
|
'next': None,
|
|
'nexttext': '',
|
|
'first': '',
|
|
'last': '',
|
|
}
|
|
if extra_template_params:
|
|
params.update(extra_template_params)
|
|
return respond(request, template, params)
|
|
|
|
|
|
def _paginate_issues(page_url,
|
|
request,
|
|
query,
|
|
template,
|
|
extra_nav_parameters=None,
|
|
extra_template_params=None):
|
|
"""Display paginated list of issues.
|
|
|
|
Args:
|
|
page_url: Base URL of issue page that is being paginated. Typically
|
|
generated by calling 'reverse' with a name and arguments of a view
|
|
function.
|
|
request: Request containing offset and limit parameters.
|
|
query: Query over issues.
|
|
template: Name of template that renders issue page.
|
|
extra_nav_parameters: Dictionary of extra parameters to append to the
|
|
navigation links.
|
|
extra_template_params: Dictionary of extra parameters to pass to page
|
|
rendering.
|
|
|
|
Returns:
|
|
Response for sending back to browser.
|
|
"""
|
|
offset = _clean_int(request.GET.get('offset'), 0, 0)
|
|
limit = _clean_int(request.GET.get('limit'), DEFAULT_LIMIT, 1, 100)
|
|
|
|
nav_parameters = {'limit': str(limit)}
|
|
if extra_nav_parameters is not None:
|
|
nav_parameters.update(extra_nav_parameters)
|
|
|
|
params = {
|
|
'limit': limit,
|
|
'first': offset + 1,
|
|
'nexttext': 'Older',
|
|
}
|
|
# Fetch one more to see if there should be a 'next' link
|
|
issues = query.fetch(limit+1, offset)
|
|
if len(issues) > limit:
|
|
del issues[limit:]
|
|
params['next'] = _url(page_url, offset=offset + limit, **nav_parameters)
|
|
params['last'] = len(issues) > 1 and offset+len(issues) or None
|
|
if offset > 0:
|
|
params['prev'] = _url(page_url, offset=max(0, offset - limit),
|
|
**nav_parameters)
|
|
if offset > limit:
|
|
params['newest'] = _url(page_url, **nav_parameters)
|
|
if extra_template_params:
|
|
params.update(extra_template_params)
|
|
return _inner_paginate(request, issues, template, params)
|
|
|
|
|
|
def _paginate_issues_with_cursor(page_url,
|
|
request,
|
|
query,
|
|
limit,
|
|
template,
|
|
extra_nav_parameters=None,
|
|
extra_template_params=None):
|
|
"""Display paginated list of issues using a cursor instead of offset.
|
|
|
|
Args:
|
|
page_url: Base URL of issue page that is being paginated. Typically
|
|
generated by calling 'reverse' with a name and arguments of a view
|
|
function.
|
|
request: Request containing offset and limit parameters.
|
|
query: Query over issues.
|
|
limit: Maximum number of issues to return.
|
|
template: Name of template that renders issue page.
|
|
extra_nav_parameters: Dictionary of extra parameters to append to the
|
|
navigation links.
|
|
extra_template_params: Dictionary of extra parameters to pass to page
|
|
rendering.
|
|
|
|
Returns:
|
|
Response for sending back to browser.
|
|
"""
|
|
issues = query.fetch(limit)
|
|
nav_parameters = {}
|
|
if extra_nav_parameters:
|
|
nav_parameters.update(extra_nav_parameters)
|
|
nav_parameters['cursor'] = query.cursor()
|
|
|
|
params = {
|
|
'limit': limit,
|
|
'cursor': nav_parameters['cursor'],
|
|
'nexttext': 'Newer',
|
|
}
|
|
# Fetch one more to see if there should be a 'next' link. Do it in a separate
|
|
# request so we have a valid cursor.
|
|
if query.fetch(1):
|
|
params['next'] = _url(page_url, **nav_parameters)
|
|
if extra_template_params:
|
|
params.update(extra_template_params)
|
|
return _inner_paginate(request, issues, template, params)
|
|
|
|
|
|
def all(request, index_call=False):
|
|
"""/all - Show a list of up to DEFAULT_LIMIT recent issues."""
|
|
closed = request.GET.get('closed', '')
|
|
if closed in ('0', 'false'):
|
|
closed = False
|
|
elif closed in ('1', 'true'):
|
|
closed = True
|
|
elif index_call:
|
|
# for index we display only open issues by default
|
|
closed = False
|
|
else:
|
|
closed = None
|
|
|
|
nav_parameters = {}
|
|
if closed is not None:
|
|
nav_parameters['closed'] = int(closed)
|
|
|
|
query = models.Issue.all().filter('private =', False)
|
|
if closed is not None:
|
|
# return only opened or closed issues
|
|
query.filter('closed =', closed)
|
|
query.order('-modified')
|
|
|
|
return _paginate_issues(reverse(all),
|
|
request,
|
|
query,
|
|
'all.html',
|
|
extra_nav_parameters=nav_parameters,
|
|
extra_template_params=dict(closed=closed))
|
|
|
|
|
|
def _optimize_draft_counts(issues):
|
|
"""Force _num_drafts to zero for issues that are known to have no drafts.
|
|
|
|
Args:
|
|
issues: list of model.Issue instances.
|
|
|
|
This inspects the drafts attribute of the current user's Account
|
|
instance, and forces the draft count to zero of those issues 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:
|
|
issue_ids = None
|
|
else:
|
|
issue_ids = account.drafts
|
|
for issue in issues:
|
|
if issue_ids is None or issue.key().id() not in issue_ids:
|
|
issue._num_drafts = 0
|
|
|
|
|
|
@login_required
|
|
def mine(request):
|
|
"""/mine - Show a list of issues created by the current user."""
|
|
request.user_to_show = request.user
|
|
return _show_user(request)
|
|
|
|
|
|
@login_required
|
|
def starred(request):
|
|
"""/starred - Show a list of issues starred by the current user."""
|
|
stars = models.Account.current_user_account.stars
|
|
if not stars:
|
|
issues = []
|
|
else:
|
|
issues = [issue for issue in models.Issue.get_by_id(stars)
|
|
if issue is not None
|
|
and _can_view_issue(request.user, issue)]
|
|
_load_users_for_issues(issues)
|
|
_optimize_draft_counts(issues)
|
|
return respond(request, 'starred.html', {'issues': issues})
|
|
|
|
def _load_users_for_issues(issues):
|
|
"""Load all user links for a list of issues in one go."""
|
|
user_dict = {}
|
|
for i in issues:
|
|
for e in i.reviewers + i.cc + [i.owner.email()]:
|
|
# keeping a count lets you track total vs. distinct if you want
|
|
user_dict[e] = user_dict.setdefault(e, 0) + 1
|
|
|
|
library.get_links_for_users(user_dict.keys())
|
|
|
|
@user_key_required
|
|
def show_user(request):
|
|
"""/user - Show the user's dashboard"""
|
|
return _show_user(request)
|
|
|
|
|
|
def _show_user(request):
|
|
user = request.user_to_show
|
|
if user == request.user:
|
|
query = models.Comment.all().filter('draft =', True)
|
|
query = query.filter('author =', request.user).fetch(100)
|
|
draft_keys = set(d.parent_key().parent().parent() for d in query)
|
|
draft_issues = models.Issue.get(draft_keys)
|
|
# Reduce the chance of someone trying to block himself.
|
|
show_block = False
|
|
else:
|
|
draft_issues = draft_keys = []
|
|
show_block = request.user_is_admin
|
|
my_issues = [
|
|
issue for issue in db.GqlQuery(
|
|
'SELECT * FROM Issue '
|
|
'WHERE closed = FALSE AND owner = :1 '
|
|
'ORDER BY modified DESC '
|
|
'LIMIT 100',
|
|
user)
|
|
if issue.key() not in draft_keys and _can_view_issue(request.user, issue)]
|
|
review_issues = [
|
|
issue for issue in db.GqlQuery(
|
|
'SELECT * FROM Issue '
|
|
'WHERE closed = FALSE AND reviewers = :1 '
|
|
'ORDER BY modified DESC '
|
|
'LIMIT 100',
|
|
user.email().lower())
|
|
if (issue.key() not in draft_keys and issue.owner != user
|
|
and _can_view_issue(request.user, issue))]
|
|
closed_issues = [
|
|
issue for issue in db.GqlQuery(
|
|
'SELECT * FROM Issue '
|
|
'WHERE closed = TRUE AND modified > :1 AND owner = :2 '
|
|
'ORDER BY modified DESC '
|
|
'LIMIT 100',
|
|
datetime.datetime.now() - datetime.timedelta(days=7),
|
|
user)
|
|
if issue.key() not in draft_keys and _can_view_issue(request.user, issue)]
|
|
cc_issues = [
|
|
issue for issue in db.GqlQuery(
|
|
'SELECT * FROM Issue '
|
|
'WHERE closed = FALSE AND cc = :1 '
|
|
'ORDER BY modified DESC '
|
|
'LIMIT 100',
|
|
user.email())
|
|
if (issue.key() not in draft_keys and issue.owner != user
|
|
and _can_view_issue(request.user, issue))]
|
|
all_issues = my_issues + review_issues + closed_issues + cc_issues
|
|
_load_users_for_issues(all_issues)
|
|
_optimize_draft_counts(all_issues)
|
|
account = models.Account.get_account_for_user(request.user_to_show)
|
|
return respond(request, 'user.html',
|
|
{'account': account,
|
|
'my_issues': my_issues,
|
|
'review_issues': review_issues,
|
|
'closed_issues': closed_issues,
|
|
'cc_issues': cc_issues,
|
|
'draft_issues': draft_issues,
|
|
'show_block': show_block,
|
|
})
|
|
|
|
|
|
@admin_required
|
|
@user_key_required
|
|
def block_user(request):
|
|
"""/user/<user>/block - Blocks a specific user."""
|
|
account = models.Account.get_account_for_user(request.user_to_show)
|
|
if request.method == 'POST':
|
|
form = BlockForm(request.POST)
|
|
if form.is_valid():
|
|
account.blocked = form.cleaned_data['blocked']
|
|
logging.debug(
|
|
'Updating block bit to %s for user %s',
|
|
account.blocked,
|
|
account.email)
|
|
account.put()
|
|
else:
|
|
form = BlockForm()
|
|
form.initial['blocked'] = account.blocked
|
|
templates = {
|
|
'account': account,
|
|
'form': form,
|
|
}
|
|
return respond(request, 'block_user.html', templates)
|
|
|
|
|
|
@login_required
|
|
@xsrf_required
|
|
def new(request):
|
|
"""/new - Upload a new patch set.
|
|
|
|
GET shows a blank form, POST processes it.
|
|
"""
|
|
if request.method != 'POST':
|
|
form = NewForm()
|
|
form.set_branch_choices()
|
|
return respond(request, 'new.html', {'form': form})
|
|
|
|
form = NewForm(request.POST, request.FILES)
|
|
form.set_branch_choices()
|
|
issue, _ = _make_new(request, form)
|
|
if issue is None:
|
|
return respond(request, 'new.html', {'form': form})
|
|
else:
|
|
return HttpResponseRedirect(reverse(show, args=[issue.key().id()]))
|
|
|
|
|
|
@login_required
|
|
@xsrf_required
|
|
def use_uploadpy(request):
|
|
"""Show an intermediate page about upload.py."""
|
|
if request.method == 'POST':
|
|
if 'disable_msg' in request.POST:
|
|
models.Account.current_user_account.uploadpy_hint = False
|
|
models.Account.current_user_account.put()
|
|
if 'download' in request.POST:
|
|
url = reverse(customized_upload_py)
|
|
else:
|
|
url = reverse(new)
|
|
return HttpResponseRedirect(url)
|
|
return respond(request, 'use_uploadpy.html')
|
|
|
|
|
|
@post_required
|
|
@upload_required
|
|
def upload(request):
|
|
"""/upload - Like new() or add(), but from the upload.py script.
|
|
|
|
This generates a text/plain response.
|
|
"""
|
|
if request.user is None:
|
|
if IS_DEV:
|
|
request.user = users.User(request.POST.get('user', 'test@example.com'))
|
|
else:
|
|
return HttpTextResponse('Login required', status=401)
|
|
# Check against old upload.py usage.
|
|
if request.POST.get('num_parts') > 1:
|
|
return HttpTextResponse('Upload.py is too old, get the latest version.')
|
|
form = UploadForm(request.POST, request.FILES)
|
|
issue = None
|
|
patchset = None
|
|
if form.is_valid():
|
|
issue_id = form.cleaned_data['issue']
|
|
if issue_id:
|
|
action = 'updated'
|
|
issue = models.Issue.get_by_id(issue_id)
|
|
if issue is None:
|
|
form.errors['issue'] = ['No issue exists with that id (%s)' %
|
|
issue_id]
|
|
elif issue.local_base and not form.cleaned_data.get('content_upload'):
|
|
form.errors['issue'] = ['Base files upload required for that issue.']
|
|
issue = None
|
|
else:
|
|
if not issue.user_can_edit(request.user):
|
|
form.errors['user'] = ['You (%s) don\'t own this issue (%s)' %
|
|
(request.user, issue_id)]
|
|
issue = None
|
|
elif issue.closed:
|
|
form.errors['issue'] = ['This issue is closed (%s)' % (issue_id)]
|
|
issue = None
|
|
else:
|
|
patchset = _add_patchset_from_form(request, issue, form, 'subject',
|
|
emails_add_only=True)
|
|
if not patchset:
|
|
issue = None
|
|
else:
|
|
action = 'created'
|
|
issue, patchset = _make_new(request, form)
|
|
if issue is None:
|
|
msg = 'Issue creation errors: %s' % repr(form.errors)
|
|
else:
|
|
msg = ('Issue %s. URL: %s' %
|
|
(action,
|
|
request.build_absolute_uri(
|
|
reverse('show_bare_issue_number', args=[issue.key().id()]))))
|
|
if (form.cleaned_data.get('content_upload') or
|
|
form.cleaned_data.get('separate_patches')):
|
|
# Extend the response message: 2nd line is patchset id.
|
|
msg +="\n%d" % patchset.key().id()
|
|
if form.cleaned_data.get('content_upload'):
|
|
# Extend the response: additional lines are the expected filenames.
|
|
issue.local_base = True
|
|
issue.put()
|
|
|
|
base_hashes = {}
|
|
for file_info in form.cleaned_data.get('base_hashes').split("|"):
|
|
if not file_info:
|
|
break
|
|
checksum, filename = file_info.split(":", 1)
|
|
base_hashes[filename] = checksum
|
|
|
|
content_entities = []
|
|
new_content_entities = []
|
|
patches = list(patchset.patch_set)
|
|
existing_patches = {}
|
|
patchsets = list(issue.patchset_set)
|
|
if len(patchsets) > 1:
|
|
# Only check the last uploaded patchset for speed.
|
|
last_patch_set = patchsets[-2].patch_set
|
|
patchsets = None # Reduce memory usage.
|
|
for opatch in last_patch_set:
|
|
if opatch.content:
|
|
existing_patches[opatch.filename] = opatch
|
|
for patch in patches:
|
|
content = None
|
|
# Check if the base file is already uploaded in another patchset.
|
|
if (patch.filename in base_hashes and
|
|
patch.filename in existing_patches and
|
|
(base_hashes[patch.filename] ==
|
|
existing_patches[patch.filename].content.checksum)):
|
|
content = existing_patches[patch.filename].content
|
|
patch.status = existing_patches[patch.filename].status
|
|
patch.is_binary = existing_patches[patch.filename].is_binary
|
|
if not content:
|
|
content = models.Content(is_uploaded=True, parent=patch)
|
|
new_content_entities.append(content)
|
|
content_entities.append(content)
|
|
existing_patches = None # Reduce memory usage.
|
|
if new_content_entities:
|
|
db.put(new_content_entities)
|
|
|
|
for patch, content_entity in zip(patches, content_entities):
|
|
patch.content = content_entity
|
|
id_string = patch.key().id()
|
|
if content_entity not in new_content_entities:
|
|
# Base file not needed since we reused a previous upload. Send its
|
|
# patch id in case it's a binary file and the new content needs to
|
|
# be uploaded. We mark this by prepending 'nobase' to the id.
|
|
id_string = "nobase_" + str(id_string)
|
|
msg += "\n%s %s" % (id_string, patch.filename)
|
|
db.put(patches)
|
|
return HttpTextResponse(msg)
|
|
|
|
|
|
@post_required
|
|
@patch_required
|
|
@upload_required
|
|
def upload_content(request):
|
|
"""/<issue>/upload_content/<patchset>/<patch> - Upload base file contents.
|
|
|
|
Used by upload.py to upload base files.
|
|
"""
|
|
form = UploadContentForm(request.POST, request.FILES)
|
|
if not form.is_valid():
|
|
return HttpTextResponse(
|
|
'ERROR: Upload content errors:\n%s' % repr(form.errors))
|
|
if request.user is None:
|
|
if IS_DEV:
|
|
request.user = users.User(request.POST.get('user', 'test@example.com'))
|
|
else:
|
|
return HttpTextResponse('Error: Login required', status=401)
|
|
if not request.issue.user_can_edit(request.user):
|
|
return HttpTextResponse('ERROR: You (%s) don\'t own this issue (%s).' %
|
|
(request.user, request.issue.key().id()))
|
|
patch = request.patch
|
|
patch.status = form.cleaned_data['status']
|
|
patch.is_binary = form.cleaned_data['is_binary']
|
|
patch.put()
|
|
|
|
if form.cleaned_data['is_current']:
|
|
if patch.patched_content:
|
|
return HttpTextResponse('ERROR: Already have current content.')
|
|
content = models.Content(is_uploaded=True, parent=patch)
|
|
content.put()
|
|
patch.patched_content = content
|
|
patch.put()
|
|
else:
|
|
content = patch.content
|
|
|
|
if form.cleaned_data['file_too_large']:
|
|
content.file_too_large = True
|
|
else:
|
|
data = form.get_uploaded_content()
|
|
checksum = md5.new(data).hexdigest()
|
|
if checksum != request.POST.get('checksum'):
|
|
content.is_bad = True
|
|
content.put()
|
|
return HttpTextResponse('ERROR: Checksum mismatch.')
|
|
if patch.is_binary:
|
|
content.data = data
|
|
else:
|
|
content.text = utils.to_dbtext(utils.unify_linebreaks(data))
|
|
content.checksum = checksum
|
|
content.put()
|
|
return HttpTextResponse('OK')
|
|
|
|
|
|
@post_required
|
|
@patchset_required
|
|
@upload_required
|
|
def upload_patch(request):
|
|
"""/<issue>/upload_patch/<patchset> - Upload patch to patchset.
|
|
|
|
Used by upload.py to upload a patch when the diff is too large to upload all
|
|
together.
|
|
"""
|
|
if request.user is None:
|
|
if IS_DEV:
|
|
request.user = users.User(request.POST.get('user', 'test@example.com'))
|
|
else:
|
|
return HttpTextResponse('Error: Login required', status=401)
|
|
if not request.issue.user_can_edit(request.user):
|
|
return HttpTextResponse(
|
|
'ERROR: You (%s) don\'t own this issue (%s).' %
|
|
(request.user, request.issue.key().id()))
|
|
form = UploadPatchForm(request.POST, request.FILES)
|
|
if not form.is_valid():
|
|
return HttpTextResponse(
|
|
'ERROR: Upload patch errors:\n%s' % repr(form.errors))
|
|
patchset = request.patchset
|
|
if patchset.data:
|
|
return HttpTextResponse(
|
|
'ERROR: Can\'t upload patches to patchset with data.')
|
|
text = utils.to_dbtext(utils.unify_linebreaks(form.get_uploaded_patch()))
|
|
patch = models.Patch(patchset=patchset,
|
|
text=text,
|
|
filename=form.cleaned_data['filename'], parent=patchset)
|
|
patch.put()
|
|
if form.cleaned_data.get('content_upload'):
|
|
content = models.Content(is_uploaded=True, parent=patch)
|
|
content.put()
|
|
patch.content = content
|
|
patch.put()
|
|
|
|
msg = 'OK\n' + str(patch.key().id())
|
|
return HttpTextResponse(msg)
|
|
|
|
|
|
@post_required
|
|
@issue_owner_required
|
|
@upload_required
|
|
def upload_complete(request, patchset_id=None):
|
|
"""/<issue>/upload_complete/<patchset> - Patchset upload is complete.
|
|
/<issue>/upload_complete/ - used when no base files are uploaded.
|
|
|
|
The following POST parameters are handled:
|
|
|
|
- send_mail: If 'yes', a notification mail will be send.
|
|
- attach_patch: If 'yes', the patches will be attached to the mail.
|
|
"""
|
|
if patchset_id is not None:
|
|
patchset = models.PatchSet.get_by_id(int(patchset_id),
|
|
parent=request.issue)
|
|
if patchset is None:
|
|
return HttpTextResponse(
|
|
'No patch set exists with that id (%s)' % patchset_id, status=403)
|
|
# Add delta calculation task.
|
|
taskqueue.add(url=reverse(calculate_delta),
|
|
params={'key': str(patchset.key())},
|
|
queue_name='deltacalculation')
|
|
else:
|
|
patchset = None
|
|
# Check for completeness
|
|
errors = []
|
|
if request.issue.local_base and patchset is not None:
|
|
query = patchset.patch_set.filter('is_binary =', False)
|
|
query = query.filter('status =', None) # all uploaded file have a status
|
|
if query.count() > 0:
|
|
errors.append('Base files missing.')
|
|
# Create (and send) a message if needed.
|
|
if request.POST.get('send_mail') == 'yes' or request.POST.get('message'):
|
|
msg = _make_message(request, request.issue, request.POST.get('message', ''),
|
|
send_mail=(request.POST.get('send_mail', '') == 'yes'))
|
|
msg.put()
|
|
_notify_issue(request, request.issue, 'Mailed')
|
|
if errors:
|
|
msg = ('The following errors occured:\n%s\n'
|
|
'Try to upload the changeset again.'
|
|
% '\n'.join(errors))
|
|
status = 500
|
|
else:
|
|
msg = 'OK'
|
|
status = 200
|
|
return HttpTextResponse(msg, status=status)
|
|
|
|
|
|
class EmptyPatchSet(Exception):
|
|
"""Exception used inside _make_new() to break out of the transaction."""
|
|
|
|
|
|
def _make_new(request, form):
|
|
"""Creates new issue and fill relevant fields from given form data.
|
|
|
|
Sends notification about created issue (if requested with send_mail param).
|
|
|
|
Returns (Issue, PatchSet) or (None, None).
|
|
"""
|
|
if not form.is_valid():
|
|
return (None, None)
|
|
account = models.Account.get_account_for_user(request.user)
|
|
if account.blocked:
|
|
# Early exit for blocked accounts.
|
|
return (None, None)
|
|
|
|
data_url = _get_data_url(form)
|
|
if data_url is None:
|
|
return (None, None)
|
|
data, url, separate_patches = data_url
|
|
|
|
reviewers = _get_emails(form, 'reviewers')
|
|
if not form.is_valid() or reviewers is None:
|
|
return (None, None)
|
|
|
|
cc = _get_emails(form, 'cc')
|
|
if not form.is_valid():
|
|
return (None, None)
|
|
|
|
base = form.get_base()
|
|
if base is None:
|
|
return (None, None)
|
|
|
|
def txn():
|
|
issue = models.Issue(subject=form.cleaned_data['subject'],
|
|
description=form.cleaned_data['description'],
|
|
base=base,
|
|
repo_guid=form.cleaned_data.get('repo_guid', None),
|
|
reviewers=reviewers,
|
|
cc=cc,
|
|
private=form.cleaned_data.get('private', False),
|
|
n_comments=0)
|
|
issue.put()
|
|
|
|
patchset = models.PatchSet(issue=issue, data=data, url=url, parent=issue)
|
|
patchset.put()
|
|
|
|
if not separate_patches:
|
|
patches = engine.ParsePatchSet(patchset)
|
|
if not patches:
|
|
raise EmptyPatchSet # Abort the transaction
|
|
db.put(patches)
|
|
return issue, patchset
|
|
|
|
try:
|
|
issue, patchset = db.run_in_transaction(txn)
|
|
except EmptyPatchSet:
|
|
errkey = url and 'url' or 'data'
|
|
form.errors[errkey] = ['Patch set contains no recognizable patches']
|
|
return (None, None)
|
|
|
|
if form.cleaned_data.get('send_mail'):
|
|
msg = _make_message(request, issue, '', '', True)
|
|
msg.put()
|
|
_notify_issue(request, issue, 'Created')
|
|
return (issue, patchset)
|
|
|
|
|
|
def _get_data_url(form):
|
|
"""Helper for _make_new() above and add() below.
|
|
|
|
Args:
|
|
form: Django form object.
|
|
|
|
Returns:
|
|
3-tuple (data, url, separate_patches).
|
|
data: the diff content, if available.
|
|
url: the url of the diff, if given.
|
|
separate_patches: True iff the patches will be uploaded separately for
|
|
each file.
|
|
|
|
"""
|
|
cleaned_data = form.cleaned_data
|
|
|
|
data = cleaned_data['data']
|
|
url = cleaned_data.get('url')
|
|
separate_patches = cleaned_data.get('separate_patches')
|
|
if not (data or url or separate_patches):
|
|
form.errors['data'] = ['You must specify a URL or upload a file (< 1 MB).']
|
|
return None
|
|
if data and url:
|
|
form.errors['data'] = ['You must specify either a URL or upload a file '
|
|
'but not both.']
|
|
return None
|
|
if separate_patches and (data or url):
|
|
form.errors['data'] = ['If the patches will be uploaded separately later, '
|
|
'you can\'t send some data or a url.']
|
|
return None
|
|
|
|
if data is not None:
|
|
data = db.Blob(utils.unify_linebreaks(data.read()))
|
|
url = None
|
|
elif url:
|
|
try:
|
|
fetch_result = urlfetch.fetch(url)
|
|
except Exception, err:
|
|
form.errors['url'] = [str(err)]
|
|
return None
|
|
if fetch_result.status_code != 200:
|
|
form.errors['url'] = ['HTTP status code %s' % fetch_result.status_code]
|
|
return None
|
|
data = db.Blob(utils.unify_linebreaks(fetch_result.content))
|
|
|
|
return data, url, separate_patches
|
|
|
|
|
|
@post_required
|
|
@issue_owner_required
|
|
@xsrf_required
|
|
def add(request):
|
|
"""/<issue>/add - Add a new PatchSet to an existing Issue."""
|
|
issue = request.issue
|
|
form = AddForm(request.POST, request.FILES)
|
|
if not _add_patchset_from_form(request, issue, form):
|
|
return show(request, issue.key().id(), form)
|
|
return HttpResponseRedirect(reverse(show, args=[issue.key().id()]))
|
|
|
|
|
|
def _add_patchset_from_form(request, issue, form, message_key='message',
|
|
emails_add_only=False):
|
|
"""Helper for add() and upload()."""
|
|
# TODO(guido): use a transaction like in _make_new(); may be share more code?
|
|
if form.is_valid():
|
|
data_url = _get_data_url(form)
|
|
if not form.is_valid():
|
|
return None
|
|
account = models.Account.get_account_for_user(request.user)
|
|
if account.blocked:
|
|
return None
|
|
if not issue.user_can_edit(request.user):
|
|
# This check is done at each call site but check again as a safety measure.
|
|
return None
|
|
data, url, separate_patches = data_url
|
|
message = form.cleaned_data[message_key]
|
|
patchset = models.PatchSet(issue=issue, message=message, data=data, url=url,
|
|
parent=issue)
|
|
patchset.put()
|
|
|
|
if not separate_patches:
|
|
patches = engine.ParsePatchSet(patchset)
|
|
if not patches:
|
|
patchset.delete()
|
|
errkey = url and 'url' or 'data'
|
|
form.errors[errkey] = ['Patch set contains no recognizable patches']
|
|
return None
|
|
db.put(patches)
|
|
|
|
if emails_add_only:
|
|
emails = _get_emails(form, 'reviewers')
|
|
if not form.is_valid():
|
|
return None
|
|
issue.reviewers += [reviewer for reviewer in emails
|
|
if reviewer not in issue.reviewers]
|
|
emails = _get_emails(form, 'cc')
|
|
if not form.is_valid():
|
|
return None
|
|
issue.cc += [cc for cc in emails if cc not in issue.cc]
|
|
else:
|
|
issue.reviewers = _get_emails(form, 'reviewers')
|
|
issue.cc = _get_emails(form, 'cc')
|
|
issue.put()
|
|
|
|
if form.cleaned_data.get('send_mail'):
|
|
msg = _make_message(request, issue, message, '', True)
|
|
msg.put()
|
|
_notify_issue(request, issue, 'Updated')
|
|
return patchset
|
|
|
|
|
|
def _get_emails(form, label):
|
|
"""Helper to return the list of reviewers, or None for error."""
|
|
raw_emails = form.cleaned_data.get(label)
|
|
if raw_emails:
|
|
return _get_emails_from_raw(raw_emails.split(','), form=form, label=label)
|
|
return []
|
|
|
|
def _get_emails_from_raw(raw_emails, form=None, label=None):
|
|
emails = []
|
|
for email in raw_emails:
|
|
email = email.strip()
|
|
if email:
|
|
try:
|
|
if '@' not in email:
|
|
account = models.Account.get_account_for_nickname(email)
|
|
if account is None:
|
|
raise db.BadValueError('Unknown user: %s' % email)
|
|
db_email = db.Email(account.user.email().lower())
|
|
elif email.count('@') != 1:
|
|
raise db.BadValueError('Invalid email address: %s' % email)
|
|
else:
|
|
_, tail = email.split('@')
|
|
if '.' not in tail:
|
|
raise db.BadValueError('Invalid email address: %s' % email)
|
|
db_email = db.Email(email.lower())
|
|
except db.BadValueError, err:
|
|
if form:
|
|
form.errors[label] = [unicode(err)]
|
|
return None
|
|
if db_email not in emails:
|
|
emails.append(db_email)
|
|
return emails
|
|
|
|
|
|
def _calculate_delta(patch, patchset_id, patchsets):
|
|
"""Calculates which files in earlier patchsets this file differs from.
|
|
|
|
Args:
|
|
patch: The file to compare.
|
|
patchset_id: The file's patchset's key id.
|
|
patchsets: A list of existing patchsets.
|
|
|
|
Returns:
|
|
A list of patchset ids.
|
|
"""
|
|
delta = []
|
|
if patch.no_base_file:
|
|
return delta
|
|
for other in patchsets:
|
|
if patchset_id == other.key().id():
|
|
break
|
|
if not hasattr(other, 'parsed_patches'):
|
|
other.parsed_patches = None # cache variable for already parsed patches
|
|
if other.data or other.parsed_patches:
|
|
# Loading all the Patch entities in every PatchSet takes too long
|
|
# (DeadLineExceeded) and consumes a lot of memory (MemoryError) so instead
|
|
# just parse the patchset's data. Note we can only do this if the
|
|
# patchset was small enough to fit in the data property.
|
|
if other.parsed_patches is None:
|
|
# PatchSet.data is stored as db.Blob (str). Try to convert it
|
|
# to unicode so that Python doesn't need to do this conversion
|
|
# when comparing text and patch.text, which is db.Text
|
|
# (unicode).
|
|
try:
|
|
other.parsed_patches = engine.SplitPatch(other.data.decode('utf-8'))
|
|
except UnicodeDecodeError: # Fallback to str - unicode comparison.
|
|
other.parsed_patches = engine.SplitPatch(other.data)
|
|
other.data = None # Reduce memory usage.
|
|
for filename, text in other.parsed_patches:
|
|
if filename == patch.filename:
|
|
if text != patch.text:
|
|
delta.append(other.key().id())
|
|
break
|
|
else:
|
|
# We could not find the file in the previous patchset. It must
|
|
# be new wrt that patchset.
|
|
delta.append(other.key().id())
|
|
else:
|
|
# other (patchset) is too big to hold all the patches inside itself, so
|
|
# we need to go to the datastore. Use the index to see if there's a
|
|
# patch against our current file in other.
|
|
query = models.Patch.all()
|
|
query.filter("filename =", patch.filename)
|
|
query.filter("patchset =", other.key())
|
|
other_patches = query.fetch(100)
|
|
if other_patches and len(other_patches) > 1:
|
|
logging.info("Got %s patches with the same filename for a patchset",
|
|
len(other_patches))
|
|
for op in other_patches:
|
|
if op.text != patch.text:
|
|
delta.append(other.key().id())
|
|
break
|
|
else:
|
|
# We could not find the file in the previous patchset. It must
|
|
# be new wrt that patchset.
|
|
delta.append(other.key().id())
|
|
|
|
return delta
|
|
|
|
|
|
def _get_patchset_info(request, patchset_id):
|
|
""" Returns a list of patchsets for the issue.
|
|
|
|
Args:
|
|
request: Django Request object.
|
|
patchset_id: The id of the patchset that the caller is interested in. This
|
|
is the one that we generate delta links to if they're not available. We
|
|
can't generate for all patchsets because it would take too long on issues
|
|
with many patchsets. Passing in None is equivalent to doing it for the
|
|
last patchset.
|
|
|
|
Returns:
|
|
A 3-tuple of (issue, patchsets, HttpResponse).
|
|
If HttpResponse is not None, further processing should stop and it should be
|
|
returned.
|
|
"""
|
|
issue = request.issue
|
|
patchsets = list(issue.patchset_set.order('created'))
|
|
response = None
|
|
if not patchset_id and patchsets:
|
|
patchset_id = patchsets[-1].key().id()
|
|
|
|
if request.user:
|
|
drafts = list(models.Comment.gql('WHERE ANCESTOR IS :1 AND draft = TRUE'
|
|
' AND author = :2',
|
|
issue, request.user))
|
|
else:
|
|
drafts = []
|
|
comments = list(models.Comment.gql('WHERE ANCESTOR IS :1 AND draft = FALSE',
|
|
issue))
|
|
issue.draft_count = len(drafts)
|
|
for c in drafts:
|
|
c.ps_key = c.patch.patchset.key()
|
|
patchset_id_mapping = {} # Maps from patchset id to its ordering number.
|
|
for patchset in patchsets:
|
|
patchset_id_mapping[patchset.key().id()] = len(patchset_id_mapping) + 1
|
|
patchset.n_drafts = sum(c.ps_key == patchset.key() for c in drafts)
|
|
patchset.patches = None
|
|
patchset.parsed_patches = None
|
|
if patchset_id == patchset.key().id():
|
|
patchset.patches = list(patchset.patch_set.order('filename'))
|
|
try:
|
|
attempt = _clean_int(request.GET.get('attempt'), 0, 0)
|
|
if attempt < 0:
|
|
response = HttpTextResponse('Invalid parameter', status=404)
|
|
break
|
|
for patch in patchset.patches:
|
|
pkey = patch.key()
|
|
patch._num_comments = sum(c.parent_key() == pkey for c in comments)
|
|
if request.user:
|
|
patch._num_my_comments = sum(
|
|
c.parent_key() == pkey and c.author == request.user
|
|
for c in comments)
|
|
else:
|
|
patch._num_my_comments = 0
|
|
patch._num_drafts = sum(c.parent_key() == pkey for c in drafts)
|
|
if not patch.delta_calculated:
|
|
if attempt > 2:
|
|
# Too many patchsets or files and we're not able to generate the
|
|
# delta links. Instead of giving a 500, try to render the page
|
|
# without them.
|
|
patch.delta = []
|
|
else:
|
|
# Compare each patch to the same file in earlier patchsets to see
|
|
# if they differ, so that we can generate the delta patch urls.
|
|
# We do this once and cache it after. It's specifically not done
|
|
# on upload because we're already doing too much processing there.
|
|
# NOTE: this function will clear out patchset.data to reduce
|
|
# memory so don't ever call patchset.put() after calling it.
|
|
patch.delta = _calculate_delta(patch, patchset_id, patchsets)
|
|
patch.delta_calculated = True
|
|
# A multi-entity put would be quicker, but it fails when the
|
|
# patches have content that is large. App Engine throws
|
|
# RequestTooLarge. This way, although not as efficient, allows
|
|
# multiple refreshes on an issue to get things done, as opposed to
|
|
# an all-or-nothing approach.
|
|
patch.put()
|
|
# Reduce memory usage: if this patchset has lots of added/removed
|
|
# files (i.e. > 100) then we'll get MemoryError when rendering the
|
|
# response. Each Patch entity is using a lot of memory if the files
|
|
# are large, since it holds the entire contents. Call num_chunks and
|
|
# num_drafts first though since they depend on text.
|
|
# These are 'active' properties and have side-effects when looked up.
|
|
# pylint: disable=W0104
|
|
patch.num_chunks
|
|
patch.num_drafts
|
|
patch.num_added
|
|
patch.num_removed
|
|
patch.text = None
|
|
patch._lines = None
|
|
patch.parsed_deltas = []
|
|
for delta in patch.delta:
|
|
patch.parsed_deltas.append([patchset_id_mapping[delta], delta])
|
|
except DeadlineExceededError:
|
|
logging.exception('DeadlineExceededError in _get_patchset_info')
|
|
if attempt > 2:
|
|
response = HttpTextResponse(
|
|
'DeadlineExceededError - create a new issue.')
|
|
else:
|
|
response = HttpResponseRedirect('%s?attempt=%d' %
|
|
(request.path, attempt + 1))
|
|
break
|
|
# Reduce memory usage (see above comment).
|
|
for patchset in patchsets:
|
|
patchset.parsed_patches = None
|
|
return issue, patchsets, response
|
|
|
|
|
|
@issue_required
|
|
def show(request, form=None):
|
|
"""/<issue> - Show an issue."""
|
|
issue, patchsets, response = _get_patchset_info(request, None)
|
|
if response:
|
|
return response
|
|
if not form:
|
|
form = AddForm(initial={'reviewers': ', '.join(issue.reviewers)})
|
|
last_patchset = first_patch = None
|
|
if patchsets:
|
|
last_patchset = patchsets[-1]
|
|
if last_patchset.patches:
|
|
first_patch = last_patchset.patches[0]
|
|
messages = []
|
|
has_draft_message = False
|
|
for msg in issue.message_set.order('date'):
|
|
if not msg.draft:
|
|
messages.append(msg)
|
|
elif msg.draft and request.user and msg.sender == request.user.email():
|
|
has_draft_message = True
|
|
num_patchsets = len(patchsets)
|
|
return respond(request, 'issue.html',
|
|
{'issue': issue, 'patchsets': patchsets,
|
|
'messages': messages, 'form': form,
|
|
'last_patchset': last_patchset,
|
|
'num_patchsets': num_patchsets,
|
|
'first_patch': first_patch,
|
|
'has_draft_message': has_draft_message,
|
|
'is_editor': issue.user_can_edit(request.user),
|
|
})
|
|
|
|
|
|
@patchset_required
|
|
def patchset(request):
|
|
"""/patchset/<key> - Returns patchset information."""
|
|
patchset = request.patchset
|
|
issue, patchsets, response = _get_patchset_info(request, patchset.key().id())
|
|
if response:
|
|
return response
|
|
for ps in patchsets:
|
|
if ps.key().id() == patchset.key().id():
|
|
patchset = ps
|
|
return respond(request, 'patchset.html',
|
|
{'issue': issue,
|
|
'patchset': patchset,
|
|
'patchsets': patchsets,
|
|
'is_editor': issue.user_can_edit(request.user),
|
|
})
|
|
|
|
|
|
@login_required
|
|
def account(request):
|
|
"""/account/?q=blah&limit=10×tamp=blah - Used for autocomplete."""
|
|
def searchAccounts(property, domain, added, response):
|
|
query = request.GET.get('q').lower()
|
|
limit = _clean_int(request.GET.get('limit'), 10, 10, 100)
|
|
|
|
accounts = models.Account.all()
|
|
accounts.filter("lower_%s >= " % property, query)
|
|
accounts.filter("lower_%s < " % property, query + u"\ufffd")
|
|
accounts.order("lower_%s" % property)
|
|
for account in accounts:
|
|
if account.key() in added:
|
|
continue
|
|
if domain and not account.email.endswith(domain):
|
|
continue
|
|
if len(added) >= limit:
|
|
break
|
|
added.add(account.key())
|
|
response += '%s (%s)\n' % (account.email, account.nickname)
|
|
return added, response
|
|
|
|
added = set()
|
|
response = ''
|
|
domain = os.environ['AUTH_DOMAIN']
|
|
if domain != 'gmail.com':
|
|
# 'gmail.com' is the value AUTH_DOMAIN is set to if the app is running
|
|
# on appspot.com and shouldn't prioritize the custom domain.
|
|
added, response = searchAccounts("email", domain, added, response)
|
|
added, response = searchAccounts("nickname", domain, added, response)
|
|
added, response = searchAccounts("nickname", "", added, response)
|
|
added, response = searchAccounts("email", "", added, response)
|
|
return HttpTextResponse(response)
|
|
|
|
|
|
@issue_editor_required
|
|
@xsrf_required
|
|
def edit(request):
|
|
"""/<issue>/edit - Edit an issue."""
|
|
issue = request.issue
|
|
base = issue.base
|
|
|
|
if issue.local_base:
|
|
form_cls = EditLocalBaseForm
|
|
else:
|
|
form_cls = EditForm
|
|
|
|
if request.method != 'POST':
|
|
reviewers = [models.Account.get_nickname_for_email(reviewer,
|
|
default=reviewer)
|
|
for reviewer in issue.reviewers]
|
|
ccs = [models.Account.get_nickname_for_email(cc, default=cc)
|
|
for cc in issue.cc]
|
|
form = form_cls(initial={'subject': issue.subject,
|
|
'description': issue.description,
|
|
'base': base,
|
|
'reviewers': ', '.join(reviewers),
|
|
'cc': ', '.join(ccs),
|
|
'closed': issue.closed,
|
|
'private': issue.private,
|
|
})
|
|
if not issue.local_base:
|
|
form.set_branch_choices(base)
|
|
return respond(request, 'edit.html', {'issue': issue, 'form': form})
|
|
|
|
form = form_cls(request.POST)
|
|
if not issue.local_base:
|
|
form.set_branch_choices()
|
|
|
|
if form.is_valid():
|
|
reviewers = _get_emails(form, 'reviewers')
|
|
|
|
if form.is_valid():
|
|
cc = _get_emails(form, 'cc')
|
|
|
|
if form.is_valid() and not issue.local_base:
|
|
base = form.get_base()
|
|
|
|
if not form.is_valid():
|
|
return respond(request, 'edit.html', {'issue': issue, 'form': form})
|
|
cleaned_data = form.cleaned_data
|
|
|
|
was_closed = issue.closed
|
|
issue.subject = cleaned_data['subject']
|
|
issue.description = cleaned_data['description']
|
|
issue.closed = cleaned_data['closed']
|
|
issue.private = cleaned_data.get('private', False)
|
|
base_changed = (issue.base != base)
|
|
issue.base = base
|
|
issue.reviewers = reviewers
|
|
issue.cc = cc
|
|
if base_changed:
|
|
for patchset in issue.patchset_set:
|
|
db.run_in_transaction(_delete_cached_contents, list(patchset.patch_set))
|
|
issue.put()
|
|
if issue.closed == was_closed:
|
|
message = 'Edited'
|
|
elif issue.closed:
|
|
message = 'Closed'
|
|
else:
|
|
message = 'Reopened'
|
|
_notify_issue(request, issue, message)
|
|
|
|
return HttpResponseRedirect(reverse(show, args=[issue.key().id()]))
|
|
|
|
|
|
def _delete_cached_contents(patch_set):
|
|
"""Transactional helper for edit() to delete cached contents."""
|
|
# TODO(guido): No need to do this in a transaction.
|
|
patches = []
|
|
contents = []
|
|
for patch in patch_set:
|
|
try:
|
|
content = patch.content
|
|
except db.Error:
|
|
content = None
|
|
try:
|
|
patched_content = patch.patched_content
|
|
except db.Error:
|
|
patched_content = None
|
|
if content is not None:
|
|
contents.append(content)
|
|
if patched_content is not None:
|
|
contents.append(patched_content)
|
|
patch.content = None
|
|
patch.patched_content = None
|
|
patches.append(patch)
|
|
if contents:
|
|
logging.info("Deleting %d contents", len(contents))
|
|
db.delete(contents)
|
|
if patches:
|
|
logging.info("Updating %d patches", len(patches))
|
|
db.put(patches)
|
|
|
|
|
|
@post_required
|
|
@issue_owner_required
|
|
@xsrf_required
|
|
def delete(request):
|
|
"""/<issue>/delete - Delete an issue. There is no way back."""
|
|
issue = request.issue
|
|
tbd = [issue]
|
|
for cls in [models.PatchSet, models.Patch, models.Comment,
|
|
models.Message, models.Content]:
|
|
tbd += cls.gql('WHERE ANCESTOR IS :1', issue)
|
|
db.delete(tbd)
|
|
_notify_issue(request, issue, 'Deleted')
|
|
return HttpResponseRedirect(reverse(mine))
|
|
|
|
|
|
@post_required
|
|
@patchset_owner_required
|
|
@xsrf_required
|
|
def delete_patchset(request):
|
|
"""/<issue>/patch/<patchset>/delete - Delete a patchset.
|
|
|
|
There is no way back.
|
|
"""
|
|
issue = request.issue
|
|
ps_delete = request.patchset
|
|
ps_id = ps_delete.key().id()
|
|
patchsets_after = issue.patchset_set.filter('created >', ps_delete.created)
|
|
patches = []
|
|
for patchset in patchsets_after:
|
|
for patch in patchset.patch_set:
|
|
if patch.delta_calculated:
|
|
if ps_id in patch.delta:
|
|
patches.append(patch)
|
|
db.run_in_transaction(_patchset_delete, ps_delete, patches)
|
|
_notify_issue(request, issue, 'Patchset deleted')
|
|
return HttpResponseRedirect(reverse(show, args=[issue.key().id()]))
|
|
|
|
|
|
def _patchset_delete(ps_delete, patches):
|
|
"""Transactional helper for delete_patchset.
|
|
|
|
Args:
|
|
ps_delete: The patchset to be deleted.
|
|
patches: Patches that have delta against patches of ps_delete.
|
|
|
|
"""
|
|
patchset_id = ps_delete.key().id()
|
|
tbp = []
|
|
for patch in patches:
|
|
patch.delta.remove(patchset_id)
|
|
tbp.append(patch)
|
|
if tbp:
|
|
db.put(tbp)
|
|
tbd = [ps_delete]
|
|
for cls in [models.Patch, models.Comment]:
|
|
tbd += cls.gql('WHERE ANCESTOR IS :1', ps_delete)
|
|
db.delete(tbd)
|
|
|
|
|
|
@post_required
|
|
@issue_editor_required
|
|
@xsrf_required
|
|
def close(request):
|
|
"""/<issue>/close - Close an issue."""
|
|
issue = request.issue
|
|
issue.closed = True
|
|
if request.method == 'POST':
|
|
new_description = request.POST.get('description')
|
|
if new_description:
|
|
issue.description = new_description
|
|
issue.put()
|
|
_notify_issue(request, issue, 'Closed')
|
|
return HttpTextResponse('Closed')
|
|
|
|
|
|
@post_required
|
|
@issue_required
|
|
@upload_required
|
|
def mailissue(request):
|
|
"""/<issue>/mail - Send mail for an issue.
|
|
|
|
This URL is deprecated and shouldn't be used anymore. However,
|
|
older versions of upload.py or wrapper scripts still may use it.
|
|
"""
|
|
if not request.issue.user_can_edit(request.user):
|
|
if not IS_DEV:
|
|
return HttpTextResponse('Login required', status=401)
|
|
issue = request.issue
|
|
msg = _make_message(request, issue, '', '', True)
|
|
msg.put()
|
|
_notify_issue(request, issue, 'Mailed')
|
|
|
|
return HttpTextResponse('OK')
|
|
|
|
|
|
@patchset_required
|
|
def download(request):
|
|
"""/download/<issue>_<patchset>.diff - Download a patch set."""
|
|
if request.patchset.data is None:
|
|
return HttpTextResponse(
|
|
'Patch set (%s) is too large.' % request.patchset.key().id(),
|
|
status=404)
|
|
padding = ''
|
|
user_agent = request.META.get('HTTP_USER_AGENT')
|
|
if user_agent and 'MSIE' in user_agent:
|
|
# Add 256+ bytes of padding to prevent XSS attacks on Internet Explorer.
|
|
padding = ('='*67 + '\n') * 4
|
|
return HttpTextResponse(padding + request.patchset.data)
|
|
|
|
|
|
@issue_required
|
|
@upload_required
|
|
def description(request):
|
|
"""/<issue>/description - Gets/Sets an issue's description.
|
|
|
|
Used by upload.py or similar scripts.
|
|
"""
|
|
if request.method != 'POST':
|
|
description = request.issue.description or ""
|
|
return HttpTextResponse(description)
|
|
if not request.issue.user_can_edit(request.user):
|
|
if not IS_DEV:
|
|
return HttpTextResponse('Login required', status=401)
|
|
issue = request.issue
|
|
issue.description = request.POST.get('description')
|
|
issue.put()
|
|
_notify_issue(request, issue, 'Changed')
|
|
return HttpTextResponse('')
|
|
|
|
|
|
@issue_required
|
|
@upload_required
|
|
@json_response
|
|
def fields(request):
|
|
"""/<issue>/fields - Gets/Sets fields on the issue.
|
|
|
|
Used by upload.py or similar scripts for partial updates of the issue
|
|
without a patchset..
|
|
"""
|
|
# Only recognizes a few fields for now.
|
|
if request.method != 'POST':
|
|
fields = request.GET.getlist('field')
|
|
response = {}
|
|
if 'reviewers' in fields:
|
|
response['reviewers'] = request.issue.reviewers or []
|
|
if 'description' in fields:
|
|
response['description'] = request.issue.description
|
|
if 'subject' in fields:
|
|
response['subject'] = request.issue.subject
|
|
return response
|
|
|
|
if not request.issue.user_can_edit(request.user):
|
|
if not IS_DEV:
|
|
return HttpTextResponse('Login required', status=401)
|
|
fields = simplejson.loads(request.POST.get('fields'))
|
|
issue = request.issue
|
|
if 'description' in fields:
|
|
issue.description = fields['description']
|
|
if 'reviewers' in fields:
|
|
issue.reviewers = _get_emails_from_raw(fields['reviewers'])
|
|
if 'subject' in fields:
|
|
issue.subject = fields['subject']
|
|
issue.put()
|
|
_notify_issue(request, issue, 'Changed')
|
|
return HttpTextResponse('')
|
|
|
|
|
|
@patch_required
|
|
def patch(request):
|
|
"""/<issue>/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.lines)
|
|
if parsed_lines is None:
|
|
return HttpTextResponse('Can\'t parse the patch to lines', status=404)
|
|
rows = engine.RenderUnifiedTableRows(request, parsed_lines)
|
|
return respond(request, 'patch.html',
|
|
{'patch': request.patch,
|
|
'patchset': request.patchset,
|
|
'view_style': 'patch',
|
|
'rows': rows,
|
|
'issue': request.issue,
|
|
'context': _clean_int(request.GET.get('context'), -1),
|
|
'column_width': _clean_int(request.GET.get('column_width'),
|
|
None),
|
|
})
|
|
|
|
|
|
@image_required
|
|
def image(request):
|
|
"""/<issue>/content/<patchset>/<patch>/<content> - Return patch's content."""
|
|
response = HttpResponse(request.content.data, content_type=request.mime_type)
|
|
filename = re.sub(
|
|
r'[^\w\.]', '_', request.patch.filename.encode('ascii', 'replace'))
|
|
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
|
|
response['Cache-Control'] = 'no-cache, no-store'
|
|
return response
|
|
|
|
|
|
@patch_required
|
|
def download_patch(request):
|
|
"""/download/issue<issue>_<patchset>_<patch>.diff - Download patch."""
|
|
return HttpTextResponse(request.patch.text)
|
|
|
|
|
|
def _issue_as_dict(issue, messages, request=None):
|
|
"""Converts an issue into a dict."""
|
|
values = {
|
|
'owner': library.get_nickname(issue.owner, True, request),
|
|
'owner_email': issue.owner.email(),
|
|
'modified': str(issue.modified),
|
|
'created': str(issue.created),
|
|
'closed': issue.closed,
|
|
'cc': issue.cc,
|
|
'reviewers': issue.reviewers,
|
|
'patchsets': [p.key().id() for p in issue.patchset_set.order('created')],
|
|
'description': issue.description,
|
|
'subject': issue.subject,
|
|
'issue': issue.key().id(),
|
|
'base_url': issue.base,
|
|
'private': issue.private,
|
|
}
|
|
if messages:
|
|
values['messages'] = [
|
|
{
|
|
'sender': m.sender,
|
|
'recipients': m.recipients,
|
|
'date': str(m.date),
|
|
'text': m.text,
|
|
'approval': m.approval,
|
|
'disapproval': m.disapproval,
|
|
}
|
|
for m in models.Message.gql('WHERE ANCESTOR IS :1', issue)
|
|
]
|
|
return values
|
|
|
|
|
|
def _patchset_as_dict(patchset, request=None):
|
|
"""Converts a patchset into a dict."""
|
|
values = {
|
|
'patchset': patchset.key().id(),
|
|
'issue': patchset.issue.key().id(),
|
|
'owner': library.get_nickname(patchset.issue.owner, True, request),
|
|
'owner_email': patchset.issue.owner.email(),
|
|
'message': patchset.message,
|
|
'url': patchset.url,
|
|
'created': str(patchset.created),
|
|
'modified': str(patchset.modified),
|
|
'num_comments': patchset.num_comments,
|
|
'files': {},
|
|
}
|
|
for patch in models.Patch.gql("WHERE patchset = :1", patchset):
|
|
# num_comments and num_drafts are left out for performance reason:
|
|
# they cause a datastore query on first access. They could be added
|
|
# optionally if the need ever arises.
|
|
values['files'][patch.filename] = {
|
|
'id': patch.key().id(),
|
|
'is_binary': patch.is_binary,
|
|
'no_base_file': patch.no_base_file,
|
|
'num_added': patch.num_added,
|
|
'num_chunks': patch.num_chunks,
|
|
'num_removed': patch.num_removed,
|
|
'status': patch.status,
|
|
'property_changes': '\n'.join(patch.property_changes),
|
|
}
|
|
return values
|
|
|
|
|
|
@issue_required
|
|
@json_response
|
|
def api_issue(request):
|
|
"""/api/<issue> - Gets issue's data as a JSON-encoded dictionary."""
|
|
messages = ('messages' in request.GET and
|
|
request.GET.get('messages').lower() == 'true')
|
|
values = _issue_as_dict(request.issue, messages, request)
|
|
return values
|
|
|
|
|
|
@patchset_required
|
|
@json_response
|
|
def api_patchset(request):
|
|
"""/api/<issue>/<patchset> - Gets an issue's patchset data as a JSON-encoded
|
|
dictionary.
|
|
"""
|
|
values = _patchset_as_dict(request.patchset, request)
|
|
return values
|
|
|
|
|
|
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
|
|
django_settings.DEFAULT_CONTEXT.
|
|
"""
|
|
get_param = request.GET.get('context') or None
|
|
if 'context' in request.GET and get_param is None:
|
|
# User wants to see whole file. No further processing is needed.
|
|
return get_param
|
|
if request.user:
|
|
account = models.Account.current_user_account
|
|
default_context = account.default_context
|
|
else:
|
|
default_context = django_settings.DEFAULT_CONTEXT
|
|
context = _clean_int(get_param, default_context)
|
|
if context is not None and context not in models.CONTEXT_CHOICES:
|
|
context = django_settings.DEFAULT_CONTEXT
|
|
return context
|
|
|
|
def _get_column_width_for_user(request):
|
|
"""Returns the column width setting for a user."""
|
|
if request.user:
|
|
account = models.Account.current_user_account
|
|
default_column_width = account.default_column_width
|
|
else:
|
|
default_column_width = django_settings.DEFAULT_COLUMN_WIDTH
|
|
column_width = _clean_int(request.GET.get('column_width'),
|
|
default_column_width,
|
|
django_settings.MIN_COLUMN_WIDTH,
|
|
django_settings.MAX_COLUMN_WIDTH)
|
|
return column_width
|
|
|
|
|
|
@patch_filename_required
|
|
def diff(request):
|
|
"""/<issue>/diff/<patchset>/<patch> - View a patch as a side-by-side diff"""
|
|
if request.patch.no_base_file:
|
|
# Can't show side-by-side diff since we don't have the base file. Show the
|
|
# unified diff instead.
|
|
return patch_helper(request, 'diff')
|
|
|
|
patchset = request.patchset
|
|
patch = request.patch
|
|
|
|
patchsets = list(request.issue.patchset_set.order('created'))
|
|
|
|
context = _get_context_for_user(request)
|
|
column_width = _get_column_width_for_user(request)
|
|
if patch.is_binary:
|
|
rows = None
|
|
else:
|
|
try:
|
|
rows = _get_diff_table_rows(request, patch, context, column_width)
|
|
except FetchError, err:
|
|
return HttpTextResponse(str(err), status=404)
|
|
|
|
_add_next_prev(patchset, patch)
|
|
return respond(request, 'diff.html',
|
|
{'issue': request.issue,
|
|
'patchset': patchset,
|
|
'patch': patch,
|
|
'view_style': 'diff',
|
|
'rows': rows,
|
|
'context': context,
|
|
'context_values': models.CONTEXT_CHOICES,
|
|
'column_width': column_width,
|
|
'patchsets': patchsets,
|
|
})
|
|
|
|
|
|
def _get_diff_table_rows(request, patch, context, column_width):
|
|
"""Helper function that returns rendered rows for a patch.
|
|
|
|
Raises:
|
|
FetchError if patch parsing or download of base files fails.
|
|
"""
|
|
chunks = patching.ParsePatchToChunks(patch.lines, patch.filename)
|
|
if chunks is None:
|
|
raise FetchError('Can\'t parse the patch to chunks')
|
|
|
|
# Possible FetchErrors are handled in diff() and diff_skipped_lines().
|
|
content = request.patch.get_content()
|
|
|
|
rows = list(engine.RenderDiffTableRows(request, content.lines,
|
|
chunks, patch,
|
|
context=context,
|
|
colwidth=column_width))
|
|
if rows and rows[-1] is None:
|
|
del rows[-1]
|
|
# Get rid of content, which may be bad
|
|
if content.is_uploaded and content.text != None:
|
|
# Don't delete uploaded content, otherwise get_content()
|
|
# will fetch it.
|
|
content.is_bad = True
|
|
content.text = None
|
|
content.put()
|
|
else:
|
|
content.delete()
|
|
request.patch.content = None
|
|
request.patch.put()
|
|
|
|
return rows
|
|
|
|
|
|
@patch_required
|
|
@json_response
|
|
def diff_skipped_lines(request, id_before, id_after, where, column_width):
|
|
"""/<issue>/diff/<patchset>/<patch> - Returns a fragment of skipped lines.
|
|
|
|
*where* indicates which lines should be expanded:
|
|
'b' - move marker line to bottom and expand above
|
|
't' - move marker line to top and expand below
|
|
'a' - expand all skipped lines
|
|
"""
|
|
patch = request.patch
|
|
if where == 'a':
|
|
context = None
|
|
else:
|
|
context = _get_context_for_user(request) or 100
|
|
|
|
column_width = _clean_int(column_width, django_settings.DEFAULT_COLUMN_WIDTH,
|
|
django_settings.MIN_COLUMN_WIDTH,
|
|
django_settings.MAX_COLUMN_WIDTH)
|
|
|
|
try:
|
|
rows = _get_diff_table_rows(request, patch, None, column_width)
|
|
except FetchError, err:
|
|
return HttpTextResponse('Error: %s; please report!' % err, status=500)
|
|
return _get_skipped_lines_response(rows, id_before, id_after, where, context)
|
|
|
|
|
|
# there's no easy way to put a control character into a regex, so brute-force it
|
|
# this is all control characters except \r, \n, and \t
|
|
_badchars_re = re.compile(
|
|
r'[\000\001\002\003\004\005\006\007\010\013\014\016\017'
|
|
r'\020\021\022\023\024\025\026\027\030\031\032\033\034\035\036\037]')
|
|
|
|
|
|
def _strip_invalid_xml(s):
|
|
"""Remove control chars other than \r\n\t from a string to be put in XML."""
|
|
if _badchars_re.search(s):
|
|
return ''.join(c for c in s if c >= ' ' or c in '\r\n\t')
|
|
else:
|
|
return s
|
|
|
|
|
|
def _get_skipped_lines_response(rows, id_before, id_after, where, context):
|
|
"""Helper function that returns response data for skipped lines"""
|
|
response_rows = []
|
|
id_before_start = int(id_before)
|
|
id_after_end = int(id_after)
|
|
if context is not None:
|
|
id_before_end = id_before_start+context
|
|
id_after_start = id_after_end-context
|
|
else:
|
|
id_before_end = id_after_start = None
|
|
|
|
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"))
|
|
# expand below marker line
|
|
if (where == 'b'
|
|
and curr_id > id_after_start and curr_id <= id_after_end):
|
|
response_rows.append(row)
|
|
# expand above marker line
|
|
elif (where == 't'
|
|
and curr_id >= id_before_start and curr_id < id_before_end):
|
|
response_rows.append(row)
|
|
# expand all skipped lines
|
|
elif (where == 'a'
|
|
and curr_id >= id_before_start and curr_id <= id_after_end):
|
|
response_rows.append(row)
|
|
if context is not None and len(response_rows) >= 2*context:
|
|
break
|
|
|
|
# Create a usable structure for the JS part
|
|
response = []
|
|
response_rows = [_strip_invalid_xml(r) for r in response_rows]
|
|
dom = ElementTree.parse(StringIO('<div>%s</div>' % "".join(response_rows)))
|
|
for node in dom.getroot().getchildren():
|
|
content = [[x.items(), x.text] for x in node.getchildren()]
|
|
response.append([node.items(), content])
|
|
return response
|
|
|
|
|
|
def _get_diff2_data(request, ps_left_id, ps_right_id, patch_id, context,
|
|
column_width, patch_filename=None):
|
|
"""Helper function that returns objects for diff2 views"""
|
|
ps_left = models.PatchSet.get_by_id(int(ps_left_id), parent=request.issue)
|
|
if ps_left is None:
|
|
return HttpTextResponse(
|
|
'No patch set exists with that id (%s)' % ps_left_id, status=404)
|
|
ps_left.issue = request.issue
|
|
ps_right = models.PatchSet.get_by_id(int(ps_right_id), parent=request.issue)
|
|
if ps_right is None:
|
|
return HttpTextResponse(
|
|
'No patch set exists with that id (%s)' % ps_right_id, status=404)
|
|
ps_right.issue = request.issue
|
|
if patch_id is not None:
|
|
patch_right = models.Patch.get_by_id(int(patch_id), parent=ps_right)
|
|
else:
|
|
patch_right = None
|
|
if patch_right is not None:
|
|
patch_right.patchset = ps_right
|
|
if patch_filename is None:
|
|
patch_filename = patch_right.filename
|
|
# Now find the corresponding patch in ps_left
|
|
patch_left = models.Patch.gql('WHERE patchset = :1 AND filename = :2',
|
|
ps_left, patch_filename).get()
|
|
|
|
if patch_left:
|
|
try:
|
|
new_content_left = patch_left.get_patched_content()
|
|
except FetchError, err:
|
|
return HttpTextResponse(str(err), status=404)
|
|
lines_left = new_content_left.lines
|
|
elif patch_right:
|
|
lines_left = patch_right.get_content().lines
|
|
else:
|
|
lines_left = []
|
|
|
|
if patch_right:
|
|
try:
|
|
new_content_right = patch_right.get_patched_content()
|
|
except FetchError, err:
|
|
return HttpTextResponse(str(err), status=404)
|
|
lines_right = new_content_right.lines
|
|
elif patch_left:
|
|
lines_right = patch_left.get_content().lines
|
|
else:
|
|
lines_right = []
|
|
|
|
rows = engine.RenderDiff2TableRows(request,
|
|
lines_left, patch_left,
|
|
lines_right, patch_right,
|
|
context=context,
|
|
colwidth=column_width)
|
|
rows = list(rows)
|
|
if rows and rows[-1] is None:
|
|
del rows[-1]
|
|
|
|
return dict(patch_left=patch_left, patch_right=patch_right,
|
|
ps_left=ps_left, ps_right=ps_right, rows=rows)
|
|
|
|
|
|
@issue_required
|
|
def diff2(request, ps_left_id, ps_right_id, patch_filename):
|
|
"""/<issue>/diff2/... - View the delta between two different patch sets."""
|
|
context = _get_context_for_user(request)
|
|
column_width = _get_column_width_for_user(request)
|
|
|
|
ps_right = models.PatchSet.get_by_id(int(ps_right_id), parent=request.issue)
|
|
patch_right = None
|
|
|
|
if ps_right:
|
|
patch_right = models.Patch.gql('WHERE patchset = :1 AND filename = :2',
|
|
ps_right, patch_filename).get()
|
|
|
|
if patch_right:
|
|
patch_id = patch_right.key().id()
|
|
elif patch_filename.isdigit():
|
|
# Perhaps it's an ID that's passed in, based on the old URL scheme.
|
|
patch_id = int(patch_filename)
|
|
else: # patch doesn't exist in this patchset
|
|
patch_id = None
|
|
|
|
data = _get_diff2_data(request, ps_left_id, ps_right_id, patch_id, context,
|
|
column_width, patch_filename)
|
|
if isinstance(data, HttpResponse) and data.status_code != 302:
|
|
return data
|
|
|
|
patchsets = list(request.issue.patchset_set.order('created'))
|
|
|
|
if data["patch_right"]:
|
|
_add_next_prev2(data["ps_left"], data["ps_right"], data["patch_right"])
|
|
return respond(request, 'diff2.html',
|
|
{'issue': request.issue,
|
|
'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,
|
|
'column_width': column_width,
|
|
'patchsets': patchsets,
|
|
'filename': patch_filename,
|
|
})
|
|
|
|
|
|
@issue_required
|
|
@json_response
|
|
def diff2_skipped_lines(request, ps_left_id, ps_right_id, patch_id,
|
|
id_before, id_after, where, column_width):
|
|
"""/<issue>/diff2/... - Returns a fragment of skipped lines"""
|
|
column_width = _clean_int(column_width, django_settings.DEFAULT_COLUMN_WIDTH,
|
|
django_settings.MIN_COLUMN_WIDTH,
|
|
django_settings.MAX_COLUMN_WIDTH)
|
|
|
|
if where == 'a':
|
|
context = None
|
|
else:
|
|
context = _get_context_for_user(request) or 100
|
|
|
|
data = _get_diff2_data(request, ps_left_id, ps_right_id, patch_id, 10000,
|
|
column_width)
|
|
if isinstance(data, HttpResponse) and data.status_code != 302:
|
|
return data
|
|
return _get_skipped_lines_response(data["rows"], id_before, id_after,
|
|
where, context)
|
|
|
|
|
|
def _get_comment_counts(account, patchset):
|
|
"""Helper to get comment counts for all patches in a single query.
|
|
|
|
The helper returns two dictionaries comments_by_patch and
|
|
drafts_by_patch with patch key as key and comment count as
|
|
value. Patches without comments or drafts are not present in those
|
|
dictionaries.
|
|
"""
|
|
# A key-only query won't work because we need to fetch the patch key
|
|
# in the for loop further down.
|
|
comment_query = models.Comment.all()
|
|
comment_query.ancestor(patchset)
|
|
|
|
# Get all comment counts with one query rather than one per patch.
|
|
comments_by_patch = {}
|
|
drafts_by_patch = {}
|
|
for c in comment_query:
|
|
pkey = models.Comment.patch.get_value_for_datastore(c)
|
|
if not c.draft:
|
|
comments_by_patch[pkey] = comments_by_patch.setdefault(pkey, 0) + 1
|
|
elif account and c.author == account.user:
|
|
drafts_by_patch[pkey] = drafts_by_patch.setdefault(pkey, 0) + 1
|
|
|
|
return comments_by_patch, drafts_by_patch
|
|
|
|
|
|
def _add_next_prev(patchset, patch):
|
|
"""Helper to add .next and .prev attributes to a patch object."""
|
|
patch.prev = patch.next = None
|
|
patches = models.Patch.all().filter('patchset =', patchset.key()).order(
|
|
'filename').fetch(1000)
|
|
patchset.patches = patches # Required to render the jump to select.
|
|
|
|
comments_by_patch, drafts_by_patch = _get_comment_counts(
|
|
models.Account.current_user_account, patchset)
|
|
|
|
last_patch = None
|
|
next_patch = None
|
|
last_patch_with_comment = None
|
|
next_patch_with_comment = None
|
|
|
|
found_patch = False
|
|
for p in patches:
|
|
if p.filename == patch.filename:
|
|
found_patch = True
|
|
continue
|
|
|
|
p._num_comments = comments_by_patch.get(p.key(), 0)
|
|
p._num_drafts = drafts_by_patch.get(p.key(), 0)
|
|
|
|
if not found_patch:
|
|
last_patch = p
|
|
if p.num_comments > 0 or p.num_drafts > 0:
|
|
last_patch_with_comment = p
|
|
else:
|
|
if next_patch is None:
|
|
next_patch = p
|
|
if p.num_comments > 0 or p.num_drafts > 0:
|
|
next_patch_with_comment = p
|
|
# safe to stop scanning now because the next with out a comment
|
|
# will already have been filled in by some earlier patch
|
|
break
|
|
|
|
patch.prev = last_patch
|
|
patch.next = next_patch
|
|
patch.prev_with_comment = last_patch_with_comment
|
|
patch.next_with_comment = next_patch_with_comment
|
|
|
|
|
|
def _add_next_prev2(ps_left, ps_right, patch_right):
|
|
"""Helper to add .next and .prev attributes to a patch object."""
|
|
patch_right.prev = patch_right.next = None
|
|
patches = list(models.Patch.gql("WHERE patchset = :1 ORDER BY filename",
|
|
ps_right))
|
|
ps_right.patches = patches # Required to render the jump to select.
|
|
|
|
n_comments, n_drafts = _get_comment_counts(
|
|
models.Account.current_user_account, ps_right)
|
|
|
|
last_patch = None
|
|
next_patch = None
|
|
last_patch_with_comment = None
|
|
next_patch_with_comment = None
|
|
|
|
found_patch = False
|
|
for p in patches:
|
|
if p.filename == patch_right.filename:
|
|
found_patch = True
|
|
continue
|
|
|
|
p._num_comments = n_comments.get(p.key(), 0)
|
|
p._num_drafts = n_drafts.get(p.key(), 0)
|
|
|
|
if not found_patch:
|
|
last_patch = p
|
|
if ((p.num_comments > 0 or p.num_drafts > 0) and
|
|
ps_left.key().id() in p.delta):
|
|
last_patch_with_comment = p
|
|
else:
|
|
if next_patch is None:
|
|
next_patch = p
|
|
if ((p.num_comments > 0 or p.num_drafts > 0) and
|
|
ps_left.key().id() in p.delta):
|
|
next_patch_with_comment = p
|
|
# safe to stop scanning now because the next with out a comment
|
|
# will already have been filled in by some earlier patch
|
|
break
|
|
|
|
patch_right.prev = last_patch
|
|
patch_right.next = next_patch
|
|
patch_right.prev_with_comment = last_patch_with_comment
|
|
patch_right.next_with_comment = next_patch_with_comment
|
|
|
|
|
|
@post_required
|
|
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.
|
|
|
|
Note: creating or editing draft comments is *not* XSRF-protected,
|
|
because it is not unusual to come back after hours; the XSRF tokens
|
|
time out after 1 or 2 hours. The final submit of the drafts for
|
|
others to view *is* XSRF-protected.
|
|
"""
|
|
try:
|
|
return _inline_draft(request)
|
|
except Exception, err:
|
|
logging.exception('Exception in inline_draft processing:')
|
|
# TODO(guido): return some kind of error instead?
|
|
# Return HttpResponse for now because the JS part expects
|
|
# a 200 status code.
|
|
return HttpHtmlResponse(
|
|
'<font color="red">Error: %s; please report!</font>' %
|
|
err.__class__.__name__)
|
|
|
|
|
|
def _inline_draft(request):
|
|
"""Helper to submit an in-line draft comment."""
|
|
# TODO(guido): turn asserts marked with XXX into errors
|
|
# Don't use @login_required, since the JS doesn't understand redirects.
|
|
if not request.user:
|
|
# Don't log this, spammers have started abusing this.
|
|
return HttpTextResponse('Not logged in')
|
|
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)
|
|
issue_id = int(request.POST['issue'])
|
|
issue = models.Issue.get_by_id(issue_id)
|
|
assert issue # XXX
|
|
patchset_id = int(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=issue)
|
|
assert patchset # XXX
|
|
patch_id = int(request.POST.get('patch') or
|
|
request.POST[side == 'a' and 'patch_left' or 'patch_right'])
|
|
patch = models.Patch.get_by_id(int(patch_id), parent=patchset)
|
|
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(issue)
|
|
else:
|
|
if comment is None:
|
|
comment = models.Comment(key_name=message_id, parent=patch)
|
|
comment.patch = patch
|
|
comment.lineno = lineno
|
|
comment.left = left
|
|
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(issue, 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 HttpTextResponse(' ')
|
|
for c in comments:
|
|
c.complete()
|
|
return render_to_response('inline_comment.html',
|
|
{'user': request.user,
|
|
'patch': patch,
|
|
'patchset': patchset,
|
|
'issue': issue,
|
|
'comments': comments,
|
|
'lineno': lineno,
|
|
'snapshot': snapshot,
|
|
'side': side,
|
|
},
|
|
context_instance=RequestContext(request))
|
|
|
|
|
|
def _get_affected_files(issue, full_diff=False):
|
|
"""Helper to return a list of affected files from the latest patchset.
|
|
|
|
Args:
|
|
issue: Issue instance.
|
|
full_diff: If true, include the entire diff even if it exceeds 100 lines.
|
|
|
|
Returns:
|
|
2-tuple containing a list of affected files, and the diff contents if it
|
|
is less than 100 lines (otherwise the second item is an empty string).
|
|
"""
|
|
files = []
|
|
modified_count = 0
|
|
diff = ''
|
|
patchsets = list(issue.patchset_set.order('created'))
|
|
if len(patchsets):
|
|
patchset = patchsets[-1]
|
|
for patch in patchset.patch_set.order('filename'):
|
|
file_str = ''
|
|
if patch.status:
|
|
file_str += patch.status + ' '
|
|
file_str += patch.filename
|
|
files.append(file_str)
|
|
# No point in loading patches if the patchset is too large for email.
|
|
if full_diff or modified_count < 100:
|
|
modified_count += patch.num_added + patch.num_removed
|
|
|
|
if full_diff or modified_count < 100:
|
|
diff = patchset.data
|
|
|
|
return files, diff
|
|
|
|
|
|
def _get_mail_template(request, issue, full_diff=False):
|
|
"""Helper to return the template and context for an email.
|
|
|
|
If this is the first email sent by the owner, a template that lists the
|
|
reviewers, description and files is used.
|
|
"""
|
|
context = {}
|
|
template = 'mails/comment.txt'
|
|
if request.user == issue.owner:
|
|
if db.GqlQuery('SELECT * FROM Message WHERE ANCESTOR IS :1 AND sender = :2',
|
|
issue, db.Email(request.user.email())).count(1) == 0:
|
|
template = 'mails/review.txt'
|
|
files, patch = _get_affected_files(issue, full_diff)
|
|
context.update({'files': files, 'patch': patch, 'base': issue.base})
|
|
return template, context
|
|
|
|
|
|
@login_required
|
|
@issue_required
|
|
@xsrf_required
|
|
def publish(request):
|
|
""" /<issue>/publish - Publish draft comments and send mail."""
|
|
issue = request.issue
|
|
if request.user == issue.owner:
|
|
form_class = PublishForm
|
|
else:
|
|
form_class = MiniPublishForm
|
|
draft_message = None
|
|
if not request.POST.get('message_only', None):
|
|
query = models.Message.gql(('WHERE issue = :1 AND sender = :2 '
|
|
'AND draft = TRUE'), issue,
|
|
request.user.email())
|
|
draft_message = query.get()
|
|
if request.method != 'POST':
|
|
reviewers = issue.reviewers[:]
|
|
cc = issue.cc[:]
|
|
if (request.user != issue.owner and
|
|
request.user.email() not in issue.reviewers and
|
|
not issue.is_collaborator(request.user)):
|
|
reviewers.append(request.user.email())
|
|
if request.user.email() in cc:
|
|
cc.remove(request.user.email())
|
|
reviewers = [models.Account.get_nickname_for_email(reviewer,
|
|
default=reviewer)
|
|
for reviewer in reviewers]
|
|
ccs = [models.Account.get_nickname_for_email(cc, default=cc) for cc in cc]
|
|
tbd, comments = _get_draft_comments(request, issue, True)
|
|
preview = _get_draft_details(request, comments)
|
|
if draft_message is None:
|
|
msg = ''
|
|
else:
|
|
msg = draft_message.text
|
|
form = form_class(initial={'subject': issue.subject,
|
|
'reviewers': ', '.join(reviewers),
|
|
'cc': ', '.join(ccs),
|
|
'send_mail': True,
|
|
'message': msg,
|
|
})
|
|
return respond(request, 'publish.html', {'form': form,
|
|
'issue': issue,
|
|
'preview': preview,
|
|
'draft_message': draft_message,
|
|
})
|
|
|
|
# Supply subject so that if this is a bare request to /publish, it won't
|
|
# fail out if we've selected PublishForm (which requires a subject).
|
|
augmented_POST = request.POST.copy()
|
|
if issue.subject:
|
|
augmented_POST.setdefault('subject', issue.subject)
|
|
form = form_class(augmented_POST)
|
|
|
|
# If the user is blocked, intentionally redirects him to the form again to
|
|
# confuse him.
|
|
account = models.Account.get_account_for_user(request.user)
|
|
if account.blocked or not form.is_valid():
|
|
return respond(request, 'publish.html', {'form': form, 'issue': issue})
|
|
if request.user == issue.owner:
|
|
issue.subject = form.cleaned_data['subject']
|
|
if form.is_valid() and not form.cleaned_data.get('message_only', False):
|
|
reviewers = _get_emails(form, 'reviewers')
|
|
else:
|
|
reviewers = issue.reviewers
|
|
if (request.user != issue.owner and
|
|
request.user.email() not in reviewers and
|
|
not issue.is_collaborator(request.user)):
|
|
reviewers.append(db.Email(request.user.email()))
|
|
if form.is_valid() and not form.cleaned_data.get('message_only', False):
|
|
cc = _get_emails(form, 'cc')
|
|
else:
|
|
cc = issue.cc
|
|
# The user is in the reviewer list, remove them from CC if they're there.
|
|
if request.user.email() in cc:
|
|
cc.remove(request.user.email())
|
|
if not form.is_valid():
|
|
return respond(request, 'publish.html', {'form': form, 'issue': issue})
|
|
issue.reviewers = reviewers
|
|
issue.cc = cc
|
|
if not form.cleaned_data.get('message_only', False):
|
|
tbd, comments = _get_draft_comments(request, issue)
|
|
else:
|
|
tbd = []
|
|
comments = []
|
|
issue.update_comment_count(len(comments))
|
|
tbd.append(issue)
|
|
|
|
if comments:
|
|
logging.warn('Publishing %d comments', len(comments))
|
|
msg = _make_message(request, issue,
|
|
form.cleaned_data['message'],
|
|
comments,
|
|
form.cleaned_data['send_mail'],
|
|
draft=draft_message,
|
|
in_reply_to=form.cleaned_data.get('in_reply_to'))
|
|
tbd.append(msg)
|
|
|
|
for obj in tbd:
|
|
db.put(obj)
|
|
|
|
_notify_issue(request, issue, 'Comments published')
|
|
|
|
# There are now no comments here (modulo race conditions)
|
|
models.Account.current_user_account.update_drafts(issue, 0)
|
|
if form.cleaned_data.get('no_redirect', False):
|
|
return HttpTextResponse('OK')
|
|
return HttpResponseRedirect(reverse(show, args=[issue.key().id()]))
|
|
|
|
|
|
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, issue, 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.
|
|
issue: Issue instance.
|
|
preview: Preview flag (default: False).
|
|
|
|
Returns:
|
|
2-tuple (put_objects, comments).
|
|
"""
|
|
comments = []
|
|
tbd = []
|
|
# XXX Should request all drafts for this issue once, now we can.
|
|
for patchset in issue.patchset_set.order('created'):
|
|
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
|
|
# Get the patch key value without loading the patch entity.
|
|
# NOTE: Unlike the old version of this code, this is the
|
|
# recommended and documented way to do this!
|
|
pkey = models.Comment.patch.get_value_for_datastore(c)
|
|
if pkey in patches:
|
|
patch = patches[pkey]
|
|
c.patch = patch
|
|
if not preview:
|
|
tbd.append(ps_comments)
|
|
patchset.update_comment_count(len(ps_comments))
|
|
tbd.append(patchset)
|
|
ps_comments.sort(key=lambda c: (c.patch.filename, not c.left,
|
|
c.lineno, c.date))
|
|
comments += ps_comments
|
|
return tbd, comments
|
|
|
|
|
|
def _patchlines2cache(patchlines, left):
|
|
"""Helper that converts return value of ParsePatchToLines for caching.
|
|
|
|
Each line in patchlines is (old_line_no, new_line_no, line). When
|
|
comment is on the left we store the old_line_no, otherwise
|
|
new_line_no.
|
|
"""
|
|
if left:
|
|
it = ((old, line) for old, _, line in patchlines)
|
|
else:
|
|
it = ((new, line) for _, new, line in patchlines)
|
|
return dict(it)
|
|
|
|
|
|
def _get_draft_details(request, comments):
|
|
"""Helper to display comments with context in the email message."""
|
|
last_key = None
|
|
output = []
|
|
linecache = {} # Maps (c.patch.key(), c.left) to mapping (lineno, line)
|
|
modified_patches = []
|
|
fetch_base_failed = False
|
|
|
|
for c in comments:
|
|
if (c.patch.key(), c.left) != last_key:
|
|
url = request.build_absolute_uri(
|
|
reverse(diff, args=[request.issue.key().id(),
|
|
c.patch.patchset.key().id(),
|
|
c.patch.filename]))
|
|
output.append('\n%s\nFile %s (%s):' % (url, c.patch.filename,
|
|
c.left and "left" or "right"))
|
|
last_key = (c.patch.key(), c.left)
|
|
patch = c.patch
|
|
if patch.no_base_file:
|
|
linecache[last_key] = _patchlines2cache(
|
|
patching.ParsePatchToLines(patch.lines), c.left)
|
|
else:
|
|
try:
|
|
if c.left:
|
|
old_lines = patch.get_content().text.splitlines(True)
|
|
linecache[last_key] = dict(enumerate(old_lines, 1))
|
|
else:
|
|
new_lines = patch.get_patched_content().text.splitlines(True)
|
|
linecache[last_key] = dict(enumerate(new_lines, 1))
|
|
except FetchError:
|
|
linecache[last_key] = _patchlines2cache(
|
|
patching.ParsePatchToLines(patch.lines), c.left)
|
|
fetch_base_failed = True
|
|
context = linecache[last_key].get(c.lineno, '').strip()
|
|
url = request.build_absolute_uri(
|
|
'%s#%scode%d' % (reverse(diff, args=[request.issue.key().id(),
|
|
c.patch.patchset.key().id(),
|
|
c.patch.filename]),
|
|
c.left and "old" or "new",
|
|
c.lineno))
|
|
output.append('\n%s\n%s:%d: %s\n%s' % (url, c.patch.filename, c.lineno,
|
|
context, c.text.rstrip()))
|
|
if modified_patches:
|
|
db.put(modified_patches)
|
|
return '\n'.join(output)
|
|
|
|
|
|
def _make_message(request, issue, message, comments=None, send_mail=False,
|
|
draft=None, in_reply_to=None):
|
|
"""Helper to create a Message instance and optionally send an email."""
|
|
attach_patch = request.POST.get("attach_patch") == "yes"
|
|
template, context = _get_mail_template(request, issue, full_diff=attach_patch)
|
|
# Decide who should receive mail
|
|
my_email = db.Email(request.user.email())
|
|
to = ([db.Email(issue.owner.email())] +
|
|
issue.reviewers +
|
|
[db.Email(email) for email in issue.collaborator_emails()])
|
|
cc = issue.cc[:]
|
|
if django_settings.RIETVELD_INCOMING_MAIL_ADDRESS:
|
|
cc.append(db.Email(django_settings.RIETVELD_INCOMING_MAIL_ADDRESS))
|
|
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)
|
|
issue_id = issue.key().id()
|
|
subject = '%s (issue %d)' % (issue.subject, issue_id)
|
|
patch = None
|
|
if attach_patch:
|
|
subject = 'PATCH: ' + subject
|
|
if 'patch' in context:
|
|
patch = context['patch']
|
|
del context['patch']
|
|
if issue.message_set.count(1) > 0:
|
|
subject = 'Re: ' + subject
|
|
if comments:
|
|
details = _get_draft_details(request, comments)
|
|
else:
|
|
details = ''
|
|
message = message.replace('\r\n', '\n')
|
|
text = ((message.strip() + '\n\n' + details.strip())).strip()
|
|
if draft is None:
|
|
msg = models.Message(issue=issue,
|
|
subject=subject,
|
|
sender=my_email,
|
|
recipients=reply_to,
|
|
text=db.Text(text),
|
|
parent=issue,
|
|
issue_was_closed=issue.closed)
|
|
else:
|
|
msg = draft
|
|
msg.subject = subject
|
|
msg.recipients = reply_to
|
|
msg.text = db.Text(text)
|
|
msg.draft = False
|
|
msg.date = datetime.datetime.now()
|
|
msg.issue_was_closed = issue.closed
|
|
|
|
if in_reply_to:
|
|
try:
|
|
msg.in_reply_to = models.Message.get(in_reply_to)
|
|
replied_issue_id = msg.in_reply_to.issue.key().id()
|
|
if replied_issue_id != issue_id:
|
|
logging.warn('In-reply-to Message is for a different issue: '
|
|
'%s instead of %s', replied_issue_id, issue_id)
|
|
msg.in_reply_to = None
|
|
except (db.KindError, db.BadKeyError):
|
|
logging.warn('Invalid in-reply-to Message or key given: %s', in_reply_to)
|
|
|
|
if send_mail:
|
|
# Limit the list of files in the email to approximately 200
|
|
if 'files' in context and len(context['files']) > 210:
|
|
num_trimmed = len(context['files']) - 200
|
|
del context['files'][200:]
|
|
context['files'].append('[[ %d additional files ]]' % num_trimmed)
|
|
url = request.build_absolute_uri(reverse(show, args=[issue.key().id()]))
|
|
reviewer_nicknames = ', '.join(library.get_nickname(rev_temp, True,
|
|
request)
|
|
for rev_temp in issue.reviewers)
|
|
cc_nicknames = ', '.join(library.get_nickname(cc_temp, True, request)
|
|
for cc_temp in cc)
|
|
my_nickname = library.get_nickname(request.user, True, request)
|
|
reply_to = ', '.join(reply_to)
|
|
description = (issue.description or '').replace('\r\n', '\n')
|
|
home = request.build_absolute_uri(reverse(index))
|
|
context.update({'reviewer_nicknames': reviewer_nicknames,
|
|
'cc_nicknames': cc_nicknames,
|
|
'my_nickname': my_nickname, 'url': url,
|
|
'message': message, 'details': details,
|
|
'description': description, 'home': home,
|
|
})
|
|
for key, value in context.iteritems():
|
|
if isinstance(value, str):
|
|
try:
|
|
encoding.force_unicode(value)
|
|
except UnicodeDecodeError:
|
|
logging.error('Key %s is not valid unicode. value: %r' % (key, value))
|
|
# The content failed to be decoded as utf-8. Enforce it as ASCII.
|
|
context[key] = value.decode('ascii', 'replace')
|
|
body = django.template.loader.render_to_string(
|
|
template, context, context_instance=RequestContext(request))
|
|
logging.warn('Mail: to=%s; cc=%s', ', '.join(to), ', '.join(cc))
|
|
send_args = {'sender': my_email,
|
|
'to': [_encode_safely(address) for address in to],
|
|
'subject': _encode_safely(subject),
|
|
'body': _encode_safely(body),
|
|
'reply_to': _encode_safely(reply_to)}
|
|
if cc:
|
|
send_args['cc'] = [_encode_safely(address) for address in cc]
|
|
if patch:
|
|
send_args['attachments'] = [('issue_%s_patch.diff' % issue.key().id(),
|
|
patch)]
|
|
|
|
attempts = 0
|
|
while True:
|
|
try:
|
|
mail.send_mail(**send_args)
|
|
break
|
|
except apiproxy_errors.DeadlineExceededError:
|
|
# apiproxy_errors.DeadlineExceededError is raised when the
|
|
# deadline of an API call is reached (e.g. for mail it's
|
|
# something about 5 seconds). It's not the same as the lethal
|
|
# runtime.DeadlineExeededError.
|
|
attempts += 1
|
|
if attempts >= 3:
|
|
raise
|
|
if attempts:
|
|
logging.warning("Retried sending email %s times", attempts)
|
|
|
|
return msg
|
|
|
|
|
|
@post_required
|
|
@login_required
|
|
@xsrf_required
|
|
@issue_required
|
|
def star(request):
|
|
"""Add a star to an Issue."""
|
|
account = models.Account.current_user_account
|
|
account.user_has_selected_nickname() # This will preserve account.fresh.
|
|
if account.stars is None:
|
|
account.stars = []
|
|
id = request.issue.key().id()
|
|
if id not in account.stars:
|
|
account.stars.append(id)
|
|
account.put()
|
|
return respond(request, 'issue_star.html', {'issue': request.issue})
|
|
|
|
|
|
@post_required
|
|
@login_required
|
|
@issue_required
|
|
@xsrf_required
|
|
def unstar(request):
|
|
"""Remove the star from an Issue."""
|
|
account = models.Account.current_user_account
|
|
account.user_has_selected_nickname() # This will preserve account.fresh.
|
|
if account.stars is None:
|
|
account.stars = []
|
|
id = request.issue.key().id()
|
|
if id in account.stars:
|
|
account.stars[:] = [i for i in account.stars if i != id]
|
|
account.put()
|
|
return respond(request, 'issue_star.html', {'issue': request.issue})
|
|
|
|
|
|
@login_required
|
|
@issue_required
|
|
def draft_message(request):
|
|
"""/<issue>/draft_message - Retrieve, modify and delete draft messages.
|
|
|
|
Note: creating or editing draft messages is *not* XSRF-protected,
|
|
because it is not unusual to come back after hours; the XSRF tokens
|
|
time out after 1 or 2 hours. The final submit of the drafts for
|
|
others to view *is* XSRF-protected.
|
|
"""
|
|
query = models.Message.gql(('WHERE issue = :1 AND sender = :2 '
|
|
'AND draft = TRUE'),
|
|
request.issue, request.user.email())
|
|
if query.count() == 0:
|
|
draft_message = None
|
|
else:
|
|
draft_message = query.get()
|
|
if request.method == 'GET':
|
|
return _get_draft_message(draft_message)
|
|
elif request.method == 'POST':
|
|
return _post_draft_message(request, draft_message)
|
|
elif request.method == 'DELETE':
|
|
return _delete_draft_message(draft_message)
|
|
return HttpTextResponse('An error occurred.', status=500)
|
|
|
|
|
|
def _get_draft_message(draft):
|
|
"""Handles GET requests to /<issue>/draft_message.
|
|
|
|
Arguments:
|
|
draft: A Message instance or None.
|
|
|
|
Returns the content of a draft message or an empty string if draft is None.
|
|
"""
|
|
return HttpTextResponse(draft.text if draft else '')
|
|
|
|
|
|
def _post_draft_message(request, draft):
|
|
"""Handles POST requests to /<issue>/draft_message.
|
|
|
|
If draft is None a new message is created.
|
|
|
|
Arguments:
|
|
request: The current request.
|
|
draft: A Message instance or None.
|
|
"""
|
|
if draft is None:
|
|
draft = models.Message(issue=request.issue, parent=request.issue,
|
|
sender=request.user.email(), draft=True)
|
|
draft.text = request.POST.get('reviewmsg')
|
|
draft.put()
|
|
return HttpTextResponse(draft.text)
|
|
|
|
|
|
def _delete_draft_message(draft):
|
|
"""Handles DELETE requests to /<issue>/draft_message.
|
|
|
|
Deletes a draft message.
|
|
|
|
Arguments:
|
|
draft: A Message instance or None.
|
|
"""
|
|
if draft is not None:
|
|
draft.delete()
|
|
return HttpTextResponse('OK')
|
|
|
|
|
|
@json_response
|
|
def search(request):
|
|
"""/search - Search for issues or patchset.
|
|
|
|
Returns HTTP 500 if the corresponding index is missing.
|
|
"""
|
|
if request.method == 'GET':
|
|
form = SearchForm(request.GET)
|
|
if not form.is_valid() or not request.GET:
|
|
return respond(request, 'search.html', {'form': form})
|
|
else:
|
|
form = SearchForm(request.POST)
|
|
if not form.is_valid():
|
|
return HttpTextResponse('Invalid arguments', status=400)
|
|
logging.info('%s' % form.cleaned_data)
|
|
keys_only = form.cleaned_data['keys_only'] or False
|
|
format = form.cleaned_data['format'] or 'html'
|
|
limit = form.cleaned_data['limit']
|
|
with_messages = form.cleaned_data['with_messages']
|
|
if format == 'html':
|
|
keys_only = False
|
|
limit = limit or DEFAULT_LIMIT
|
|
else:
|
|
if not limit:
|
|
if keys_only:
|
|
# It's a fast query.
|
|
limit = 1000
|
|
elif with_messages:
|
|
# It's an heavy query.
|
|
limit = 10
|
|
else:
|
|
limit = 100
|
|
|
|
q = models.Issue.all(keys_only=keys_only)
|
|
if form.cleaned_data['cursor']:
|
|
q.with_cursor(form.cleaned_data['cursor'])
|
|
if form.cleaned_data['closed'] is not None:
|
|
q.filter('closed = ', form.cleaned_data['closed'])
|
|
if form.cleaned_data['owner']:
|
|
q.filter('owner = ', form.cleaned_data['owner'])
|
|
if form.cleaned_data['reviewer']:
|
|
q.filter('reviewers = ', form.cleaned_data['reviewer'])
|
|
if form.cleaned_data['private'] is not None:
|
|
q.filter('private = ', form.cleaned_data['private'])
|
|
if form.cleaned_data['repo_guid']:
|
|
q.filter('repo_guid = ', form.cleaned_data['repo_guid'])
|
|
if form.cleaned_data['base']:
|
|
q.filter('base = ', form.cleaned_data['base'])
|
|
|
|
# Calculate a default value depending on the query parameter.
|
|
# Prefer sorting by modified date over created date and showing
|
|
# newest first over oldest.
|
|
default_sort = '-modified'
|
|
if form.cleaned_data['created_after']:
|
|
q.filter('created >= ', form.cleaned_data['created_after'])
|
|
default_sort = 'created'
|
|
if form.cleaned_data['modified_after']:
|
|
q.filter('modified >= ', form.cleaned_data['modified_after'])
|
|
default_sort = 'modified'
|
|
if form.cleaned_data['created_before']:
|
|
q.filter('created < ', form.cleaned_data['created_before'])
|
|
default_sort = '-created'
|
|
if form.cleaned_data['modified_before']:
|
|
q.filter('modified < ', form.cleaned_data['modified_before'])
|
|
default_sort = '-modified'
|
|
|
|
sorted_by = form.cleaned_data['order'] or default_sort
|
|
q.order(sorted_by)
|
|
|
|
# Update the cursor value in the result.
|
|
if format == 'html':
|
|
nav_params = dict(
|
|
(k, v) for k, v in form.cleaned_data.iteritems() if v is not None)
|
|
return _paginate_issues_with_cursor(
|
|
reverse(search),
|
|
request,
|
|
q,
|
|
limit,
|
|
'search_results.html',
|
|
extra_nav_parameters=nav_params)
|
|
|
|
results = q.fetch(limit)
|
|
form.cleaned_data['cursor'] = q.cursor()
|
|
if keys_only:
|
|
# There's not enough information to filter. The only thing that is leaked is
|
|
# the issue's key.
|
|
filtered_results = results
|
|
else:
|
|
filtered_results = [i for i in results if _can_view_issue(request.user, i)]
|
|
data = {
|
|
'cursor': form.cleaned_data['cursor'],
|
|
}
|
|
if keys_only:
|
|
data['results'] = [i.id() for i in filtered_results]
|
|
else:
|
|
data['results'] = [_issue_as_dict(i, with_messages, request)
|
|
for i in filtered_results]
|
|
return data
|
|
|
|
|
|
### Repositories and Branches ###
|
|
|
|
|
|
def repos(request):
|
|
"""/repos - Show the list of known Subversion repositories."""
|
|
# Clean up garbage created by buggy edits
|
|
bad_branches = models.Branch.gql('WHERE owner = :1', None).fetch(100)
|
|
if bad_branches:
|
|
db.delete(bad_branches)
|
|
repo_map = {}
|
|
for repo in models.Repository.all().fetch(1000, batch_size=100):
|
|
repo_map[str(repo.key())] = repo
|
|
branches = []
|
|
for branch in models.Branch.all().fetch(2000, batch_size=100):
|
|
# Using ._repo instead of .repo returns the db.Key of the referenced entity.
|
|
# Access to a protected member FOO of a client class
|
|
# pylint: disable=W0212
|
|
branch.repository = repo_map[str(branch._repo)]
|
|
branches.append(branch)
|
|
branches.sort(key=lambda b: map(
|
|
unicode.lower, (b.repository.name, b.category, b.name)))
|
|
return respond(request, 'repos.html', {'branches': branches})
|
|
|
|
|
|
@login_required
|
|
@xsrf_required
|
|
def repo_new(request):
|
|
"""/repo_new - Create a new Subversion repository record."""
|
|
if request.method != 'POST':
|
|
form = RepoForm()
|
|
return respond(request, 'repo_new.html', {'form': form})
|
|
form = RepoForm(request.POST)
|
|
errors = form.errors
|
|
if not errors:
|
|
try:
|
|
repo = models.Repository(
|
|
name=form.cleaned_data.get('name'),
|
|
url=form.cleaned_data.get('url'),
|
|
guid=form.cleaned_data.get('guid'),
|
|
)
|
|
except (db.BadValueError, ValueError), err:
|
|
errors['__all__'] = unicode(err)
|
|
if errors:
|
|
return respond(request, 'repo_new.html', {'form': form})
|
|
repo.put()
|
|
branch_url = repo.url
|
|
if not branch_url.endswith('/'):
|
|
branch_url += '/'
|
|
branch_url += 'trunk/'
|
|
branch = models.Branch(repo=repo, repo_name=repo.name,
|
|
category='*trunk*', name='Trunk',
|
|
url=branch_url)
|
|
branch.put()
|
|
return HttpResponseRedirect(reverse(repos))
|
|
|
|
|
|
SVN_ROOT = 'http://svn.python.org/view/*checkout*/python/'
|
|
BRANCHES = [
|
|
# category, name, url suffix
|
|
('*trunk*', 'Trunk', 'trunk/'),
|
|
('branch', '2.5', 'branches/release25-maint/'),
|
|
('branch', 'py3k', 'branches/py3k/'),
|
|
]
|
|
|
|
|
|
# TODO: Make this a POST request to avoid XSRF attacks.
|
|
@admin_required
|
|
def repo_init(_request):
|
|
"""/repo_init - Initialze the list of known Subversion repositories."""
|
|
python = models.Repository.gql("WHERE name = 'Python'").get()
|
|
if python is None:
|
|
python = models.Repository(name='Python', url=SVN_ROOT)
|
|
python.put()
|
|
pybranches = []
|
|
else:
|
|
pybranches = list(models.Branch.gql('WHERE repo = :1', python))
|
|
for category, name, url in BRANCHES:
|
|
url = python.url + url
|
|
for br in pybranches:
|
|
if (br.category, br.name, br.url) == (category, name, url):
|
|
break
|
|
else:
|
|
br = models.Branch(repo=python, repo_name='Python',
|
|
category=category, name=name, url=url)
|
|
br.put()
|
|
return HttpResponseRedirect(reverse(repos))
|
|
|
|
|
|
@login_required
|
|
@xsrf_required
|
|
def branch_new(request, repo_id):
|
|
"""/branch_new/<repo> - Add a new Branch to a Repository record."""
|
|
repo = models.Repository.get_by_id(int(repo_id))
|
|
if request.method != 'POST':
|
|
form = BranchForm(initial={'url': repo.url,
|
|
'category': 'branch',
|
|
})
|
|
return respond(request, 'branch_new.html', {'form': form, 'repo': repo})
|
|
form = BranchForm(request.POST)
|
|
errors = form.errors
|
|
if not errors:
|
|
try:
|
|
branch = models.Branch(
|
|
repo=repo,
|
|
category=form.cleaned_data.get('category'),
|
|
name=form.cleaned_data.get('name'),
|
|
url=form.cleaned_data.get('url'),
|
|
)
|
|
except (db.BadValueError, ValueError), err:
|
|
errors['__all__'] = unicode(err)
|
|
if errors:
|
|
return respond(request, 'branch_new.html', {'form': form, 'repo': repo})
|
|
branch.repo_name = repo.name
|
|
branch.put()
|
|
return HttpResponseRedirect(reverse(repos))
|
|
|
|
|
|
@login_required
|
|
@xsrf_required
|
|
def branch_edit(request, branch_id):
|
|
"""/branch_edit/<branch> - Edit a Branch record."""
|
|
branch = models.Branch.get_by_id(int(branch_id))
|
|
if branch.owner != request.user:
|
|
return HttpTextResponse('You do not own this branch', status=403)
|
|
if request.method != 'POST':
|
|
form = BranchForm(initial={'category': branch.category,
|
|
'name': branch.name,
|
|
'url': branch.url,
|
|
})
|
|
return respond(request, 'branch_edit.html',
|
|
{'branch': branch, 'form': form})
|
|
|
|
form = BranchForm(request.POST)
|
|
errors = form.errors
|
|
if not errors:
|
|
try:
|
|
branch.category = form.cleaned_data.get('category')
|
|
branch.name = form.cleaned_data.get('name')
|
|
branch.url = form.cleaned_data.get('url')
|
|
except (db.BadValueError, ValueError), err:
|
|
errors['__all__'] = unicode(err)
|
|
if errors:
|
|
return respond(request, 'branch_edit.html',
|
|
{'branch': branch, 'form': form})
|
|
branch.put()
|
|
return HttpResponseRedirect(reverse(repos))
|
|
|
|
|
|
@post_required
|
|
@login_required
|
|
@xsrf_required
|
|
def branch_delete(request, branch_id):
|
|
"""/branch_delete/<branch> - Delete a Branch record."""
|
|
branch = models.Branch.get_by_id(int(branch_id))
|
|
if branch.owner != request.user:
|
|
return HttpTextResponse('You do not own this branch', status=403)
|
|
repo = branch.repo
|
|
branch.delete()
|
|
num_branches = models.Branch.gql('WHERE repo = :1', repo).count()
|
|
if not num_branches:
|
|
# Even if we don't own the repository? Yes, I think so! Empty
|
|
# repositories have no representation on screen.
|
|
repo.delete()
|
|
return HttpResponseRedirect(reverse(repos))
|
|
|
|
|
|
### User Profiles ###
|
|
|
|
|
|
@login_required
|
|
@xsrf_required
|
|
def settings(request):
|
|
account = models.Account.current_user_account
|
|
if request.method != 'POST':
|
|
nickname = account.nickname
|
|
default_context = account.default_context
|
|
default_column_width = account.default_column_width
|
|
form = SettingsForm(initial={'nickname': nickname,
|
|
'context': default_context,
|
|
'column_width': default_column_width,
|
|
'notify_by_email': account.notify_by_email,
|
|
'notify_by_chat': account.notify_by_chat,
|
|
})
|
|
chat_status = None
|
|
if account.notify_by_chat:
|
|
try:
|
|
presence = xmpp.get_presence(account.email)
|
|
except Exception, err:
|
|
logging.error('Exception getting XMPP presence: %s', err)
|
|
chat_status = 'Error (%s)' % err
|
|
else:
|
|
if presence:
|
|
chat_status = 'online'
|
|
else:
|
|
chat_status = 'offline'
|
|
return respond(request, 'settings.html', {'form': form,
|
|
'chat_status': chat_status})
|
|
form = SettingsForm(request.POST)
|
|
if form.is_valid():
|
|
account.nickname = form.cleaned_data.get('nickname')
|
|
account.default_context = form.cleaned_data.get('context')
|
|
account.default_column_width = form.cleaned_data.get('column_width')
|
|
account.notify_by_email = form.cleaned_data.get('notify_by_email')
|
|
notify_by_chat = form.cleaned_data.get('notify_by_chat')
|
|
must_invite = notify_by_chat and not account.notify_by_chat
|
|
account.notify_by_chat = notify_by_chat
|
|
account.fresh = False
|
|
account.put()
|
|
if must_invite:
|
|
logging.info('Sending XMPP invite to %s', account.email)
|
|
try:
|
|
xmpp.send_invite(account.email)
|
|
except Exception, err:
|
|
# XXX How to tell user it failed?
|
|
logging.error('XMPP invite to %s failed', account.email)
|
|
else:
|
|
return respond(request, 'settings.html', {'form': form})
|
|
return HttpResponseRedirect(reverse(mine))
|
|
|
|
|
|
@post_required
|
|
@login_required
|
|
@xsrf_required
|
|
def account_delete(_request):
|
|
account = models.Account.current_user_account
|
|
account.delete()
|
|
return HttpResponseRedirect(users.create_logout_url(reverse(index)))
|
|
|
|
|
|
@login_required
|
|
@xsrf_required
|
|
def migrate_entities(request):
|
|
msg = None
|
|
if request.method == 'POST':
|
|
form = MigrateEntitiesForm(request.POST)
|
|
form.set_user(request.user)
|
|
if form.is_valid():
|
|
# verify that the account belongs to the user
|
|
old_account = form.cleaned_data['account']
|
|
old_account_key = str(old_account.key())
|
|
new_account_key = str(models.Account.current_user_account.key())
|
|
for kind in ('Issue', 'Repository', 'Branch'):
|
|
taskqueue.add(url=reverse(task_migrate_entities),
|
|
params={'kind': kind,
|
|
'old': old_account_key,
|
|
'new': new_account_key})
|
|
msg = (u'Migration job started. The issues, repositories and branches'
|
|
u' created with your old account (%s) will be moved to your'
|
|
u' current account (%s) in a background task and should'
|
|
u' be visible for your current account shortly.'
|
|
% (old_account.user.email(), request.user.email()))
|
|
else:
|
|
form = MigrateEntitiesForm()
|
|
return respond(request, 'migrate_entities.html', {'form': form, 'msg': msg})
|
|
|
|
|
|
@post_required
|
|
def task_migrate_entities(request):
|
|
"""/tasks/migrate_entities - Migrates entities from one account to another."""
|
|
kind = request.POST.get('kind')
|
|
old = request.POST.get('old')
|
|
new = request.POST.get('new')
|
|
batch_size = 20
|
|
if kind is None or old is None or new is None:
|
|
logging.warning('Missing parameters')
|
|
return HttpResponse()
|
|
if kind not in ('Issue', 'Repository', 'Branch'):
|
|
logging.warning('Invalid kind: %s' % kind)
|
|
return HttpResponse()
|
|
old_account = models.Account.get(db.Key(old))
|
|
new_account = models.Account.get(db.Key(new))
|
|
if old_account is None or new_account is None:
|
|
logging.warning('Invalid accounts')
|
|
return HttpResponse()
|
|
# make sure that accounts match
|
|
if old_account.user.user_id() != new_account.user.user_id():
|
|
logging.warning('Accounts don\'t match')
|
|
return HttpResponse()
|
|
model = getattr(models, kind)
|
|
key = request.POST.get('key')
|
|
query = model.all().filter('owner =', old_account.user)
|
|
if key:
|
|
query = query.filter('__key__ >', db.Key(key))
|
|
query = query.order('__key__')
|
|
tbd = []
|
|
for entity in query.fetch(batch_size):
|
|
entity.owner = new_account.user
|
|
tbd.append(entity)
|
|
if tbd:
|
|
db.put(tbd)
|
|
taskqueue.add(url=reverse(task_migrate_entities),
|
|
params={'kind': kind, 'old': old, 'new': new,
|
|
'key': str(tbd[-1].key())})
|
|
return HttpResponse()
|
|
|
|
|
|
@user_key_required
|
|
def user_popup(request):
|
|
"""/user_popup - Pop up to show the user info."""
|
|
try:
|
|
return _user_popup(request)
|
|
except Exception, err:
|
|
logging.exception('Exception in user_popup processing:')
|
|
# Return HttpResponse because the JS part expects a 200 status code.
|
|
return HttpHtmlResponse(
|
|
'<font color="red">Error: %s; please report!</font>' %
|
|
err.__class__.__name__)
|
|
|
|
|
|
def _user_popup(request):
|
|
user = request.user_to_show
|
|
popup_html = memcache.get('user_popup:' + user.email())
|
|
if popup_html is None:
|
|
num_issues_created = db.GqlQuery(
|
|
'SELECT * FROM Issue '
|
|
'WHERE closed = FALSE AND owner = :1',
|
|
user).count()
|
|
num_issues_reviewed = db.GqlQuery(
|
|
'SELECT * FROM Issue '
|
|
'WHERE closed = FALSE AND reviewers = :1',
|
|
user.email()).count()
|
|
|
|
user.nickname = models.Account.get_nickname_for_email(user.email())
|
|
popup_html = render_to_response('user_popup.html',
|
|
{'user': user,
|
|
'num_issues_created': num_issues_created,
|
|
'num_issues_reviewed': num_issues_reviewed,
|
|
},
|
|
context_instance=RequestContext(request))
|
|
# Use time expired cache because the number of issues will change over time
|
|
memcache.add('user_popup:' + user.email(), popup_html, 60)
|
|
return popup_html
|
|
|
|
|
|
@post_required
|
|
def incoming_chat(request):
|
|
"""/_ah/xmpp/message/chat/
|
|
|
|
This handles incoming XMPP (chat) messages.
|
|
|
|
Just reply saying we ignored the chat.
|
|
"""
|
|
try:
|
|
msg = xmpp.Message(request.POST)
|
|
except xmpp.InvalidMessageError, err:
|
|
logging.warn('Incoming invalid chat message: %s' % err)
|
|
return HttpTextResponse('')
|
|
sts = msg.reply('Sorry, Rietveld does not support chat input')
|
|
logging.debug('XMPP status %r', sts)
|
|
return HttpTextResponse('')
|
|
|
|
|
|
@post_required
|
|
def incoming_mail(request, recipients):
|
|
"""/_ah/mail/(.*)
|
|
|
|
Handle incoming mail messages.
|
|
|
|
The issue is not modified. No reviewers or CC's will be added or removed.
|
|
"""
|
|
try:
|
|
_process_incoming_mail(request.raw_post_data, recipients)
|
|
except InvalidIncomingEmailError, err:
|
|
logging.debug(str(err))
|
|
return HttpTextResponse('')
|
|
|
|
|
|
def _process_incoming_mail(raw_message, recipients):
|
|
"""Process an incoming email message."""
|
|
recipients = [x[1] for x in email.utils.getaddresses([recipients])]
|
|
|
|
incoming_msg = mail.InboundEmailMessage(raw_message)
|
|
|
|
if 'X-Google-Appengine-App-Id' in incoming_msg.original:
|
|
raise InvalidIncomingEmailError('Mail sent by App Engine')
|
|
|
|
subject = incoming_msg.subject or ''
|
|
match = re.search(r'\(issue *(?P<id>\d+)\)$', subject)
|
|
if match is None:
|
|
raise InvalidIncomingEmailError('No issue id found: %s', subject)
|
|
issue_id = int(match.groupdict()['id'])
|
|
issue = models.Issue.get_by_id(issue_id)
|
|
if issue is None:
|
|
raise InvalidIncomingEmailError('Unknown issue ID: %d' % issue_id)
|
|
sender = email.utils.parseaddr(incoming_msg.sender)[1]
|
|
|
|
body = None
|
|
for _, payload in incoming_msg.bodies('text/plain'):
|
|
# FIXME(andi): Remove this when issue 2383 is fixed.
|
|
# 8bit encoding results in UnknownEncodingError, see
|
|
# http://code.google.com/p/googleappengine/issues/detail?id=2383
|
|
# As a workaround we try to decode the payload ourselves.
|
|
if payload.encoding == '8bit' and payload.charset:
|
|
body = payload.payload.decode(payload.charset)
|
|
# If neither encoding not charset is set, but payload contains
|
|
# non-ASCII chars we can't use payload.decode() because it returns
|
|
# payload.payload unmodified. The later type cast to db.Text fails
|
|
# with a UnicodeDecodeError then.
|
|
elif payload.encoding is None and payload.charset is None:
|
|
# assume utf-8 but set replace flag to go for sure.
|
|
body = payload.payload.decode('utf-8', 'replace')
|
|
else:
|
|
body = payload.decode()
|
|
break
|
|
if body is None or not body.strip():
|
|
raise InvalidIncomingEmailError('Ignoring empty message.')
|
|
elif len(body) > django_settings.RIETVELD_INCOMING_MAIL_MAX_SIZE:
|
|
# see issue325, truncate huge bodies
|
|
trunc_msg = '... (message truncated)'
|
|
end = django_settings.RIETVELD_INCOMING_MAIL_MAX_SIZE - len(trunc_msg)
|
|
body = body[:end]
|
|
body += trunc_msg
|
|
|
|
# If the subject is long, this might come wrapped into more than one line.
|
|
subject = ' '.join([x.strip() for x in subject.splitlines()])
|
|
msg = models.Message(issue=issue, parent=issue,
|
|
subject=subject,
|
|
sender=db.Email(sender),
|
|
recipients=[db.Email(x) for x in recipients],
|
|
date=datetime.datetime.now(),
|
|
text=db.Text(body),
|
|
draft=False)
|
|
msg.put()
|
|
|
|
# Add sender to reviewers if needed.
|
|
all_emails = [str(x).lower()
|
|
for x in ([issue.owner.email()] +
|
|
issue.reviewers +
|
|
issue.cc +
|
|
issue.collaborator_emails())]
|
|
if sender.lower() not in all_emails:
|
|
query = models.Account.all().filter('lower_email =', sender.lower())
|
|
account = query.get()
|
|
if account is not None:
|
|
issue.reviewers.append(account.email) # e.g. account.email is CamelCase
|
|
else:
|
|
issue.reviewers.append(db.Email(sender))
|
|
issue.put()
|
|
|
|
|
|
@login_required
|
|
def xsrf_token(request):
|
|
"""/xsrf_token - Return the user's XSRF token.
|
|
|
|
This is used by tools like git-cl that need to be able to interact with the
|
|
site on the user's behalf. A custom header named X-Requesting-XSRF-Token must
|
|
be included in the HTTP request; an error is returned otherwise.
|
|
"""
|
|
if not request.META.has_key('HTTP_X_REQUESTING_XSRF_TOKEN'):
|
|
return HttpTextResponse(
|
|
'Please include a header named X-Requesting-XSRF-Token '
|
|
'(its content doesn\'t matter).',
|
|
status=400)
|
|
return HttpTextResponse(models.Account.current_user_account.get_xsrf_token())
|
|
|
|
|
|
def customized_upload_py(request):
|
|
"""/static/upload.py - Return patched upload.py with appropiate auth type and
|
|
default review server setting.
|
|
|
|
This is used to let the user download a customized upload.py script
|
|
for hosted Rietveld instances.
|
|
"""
|
|
f = open(django_settings.UPLOAD_PY_SOURCE)
|
|
source = f.read()
|
|
f.close()
|
|
|
|
# When served from a Google Apps instance, the account namespace needs to be
|
|
# switched to "Google Apps only".
|
|
if ('AUTH_DOMAIN' in request.META
|
|
and request.META['AUTH_DOMAIN'] != 'gmail.com'):
|
|
source = source.replace('AUTH_ACCOUNT_TYPE = "GOOGLE"',
|
|
'AUTH_ACCOUNT_TYPE = "HOSTED"')
|
|
|
|
# On a non-standard instance, the default review server is changed to the
|
|
# current hostname. This might give weird results when using versioned appspot
|
|
# URLs (eg. 1.latest.codereview.appspot.com), but this should only affect
|
|
# testing.
|
|
if request.META['HTTP_HOST'] != 'codereview.appspot.com':
|
|
review_server = request.META['HTTP_HOST']
|
|
if request.is_secure():
|
|
review_server = 'https://' + review_server
|
|
source = source.replace('DEFAULT_REVIEW_SERVER = "codereview.appspot.com"',
|
|
'DEFAULT_REVIEW_SERVER = "%s"' % review_server)
|
|
|
|
return HttpResponse(source, content_type='text/x-python; charset=utf-8')
|
|
|
|
|
|
@post_required
|
|
def calculate_delta(request):
|
|
"""/calculate_delta - Calculate deltas for a patchset.
|
|
|
|
This URL is called by taskqueue to calculate deltas behind the
|
|
scenes. Returning a HttpResponse with any 2xx status means that the
|
|
task was finished successfully. Raising an exception means that the
|
|
taskqueue will retry to run the task.
|
|
|
|
This code is similar to the code in _get_patchset_info() which is
|
|
run when a patchset should be displayed in the UI.
|
|
"""
|
|
key = request.POST.get('key')
|
|
if not key:
|
|
logging.debug('No key given.')
|
|
return HttpResponse()
|
|
try:
|
|
patchset = models.PatchSet.get(key)
|
|
except (db.KindError, db.BadKeyError), err:
|
|
logging.debug('Invalid PatchSet key %r: %s' % (key, err))
|
|
return HttpResponse()
|
|
if patchset is None: # e.g. PatchSet was deleted inbetween
|
|
return HttpResponse()
|
|
patchset_id = patchset.key().id()
|
|
patchsets = None
|
|
for patch in patchset.patch_set.filter('delta_calculated =', False):
|
|
if patchsets is None:
|
|
# patchsets is retrieved on first iteration because patchsets
|
|
# isn't needed outside the loop at all.
|
|
patchsets = list(patchset.issue.patchset_set.order('created'))
|
|
patch.delta = _calculate_delta(patch, patchset_id, patchsets)
|
|
patch.delta_calculated = True
|
|
patch.put()
|
|
return HttpResponse()
|