| LEFT | RIGHT |
| 1 # Copyright 2008 Google Inc. | 1 # Copyright 2008 Google Inc. |
| 2 # | 2 # |
| 3 # Licensed under the Apache License, Version 2.0 (the "License"); | 3 # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 # you may not use this file except in compliance with the License. | 4 # you may not use this file except in compliance with the License. |
| 5 # You may obtain a copy of the License at | 5 # You may obtain a copy of the License at |
| 6 # | 6 # |
| 7 # http://www.apache.org/licenses/LICENSE-2.0 | 7 # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 # | 8 # |
| 9 # Unless required by applicable law or agreed to in writing, software | 9 # Unless required by applicable law or agreed to in writing, software |
| 10 # distributed under the License is distributed on an "AS IS" BASIS, | 10 # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 # See the License for the specific language governing permissions and | 12 # See the License for the specific language governing permissions and |
| 13 # limitations under the License. | 13 # limitations under the License. |
| 14 | 14 |
| 15 """Views for Rietveld. | 15 """Views for Rietveld. |
| 16 | 16 |
| 17 This requires Django 0.97.pre. | 17 This requires Django 0.97.pre. |
| 18 """ | 18 """ |
| 19 | 19 |
| 20 | 20 |
| 21 ### Imports ### | 21 ### Imports ### |
| 22 | 22 |
| 23 | 23 |
| 24 # Python imports | 24 # Python imports |
| 25 import os | 25 import os |
| 26 import cgi | 26 import cgi |
| 27 import random | 27 import random |
| 28 import logging | 28 import logging |
| 29 import binascii | 29 import binascii |
| 30 | 30 |
| 31 # AppEngine imports | 31 # AppEngine imports |
| 32 from google.appengine.api import mail | 32 from google.appengine.api import mail |
| 33 from google.appengine.api import users | 33 from google.appengine.api import users |
| 34 from google.appengine.api import urlfetch | 34 from google.appengine.api import urlfetch |
| 35 from google.appengine.ext import db | 35 from google.appengine.ext import db |
| 36 from google.appengine.ext.db import djangoforms | 36 from google.appengine.ext.db import djangoforms |
| 37 | 37 |
| 38 # DeadlineExceededError can live in two different places | 38 # DeadlineExceededError can live in two different places |
| 39 # TODO(guido): simplify once this is fixed. | 39 # TODO(guido): simplify once this is fixed. |
| 40 try: | 40 try: |
| 41 # When deployed | 41 # When deployed |
| 42 from google.appengine.runtime import DeadlineExceededError | 42 from google.appengine.runtime import DeadlineExceededError |
| 43 except ImportError: | 43 except ImportError: |
| 44 # In the development server | 44 # In the development server |
| 45 from google.appengine.runtime.apiproxy_errors import DeadlineExceededError | 45 from google.appengine.runtime.apiproxy_errors import DeadlineExceededError |
| 46 | 46 |
| 47 # Django imports | 47 # Django imports |
| 48 # TODO(guido): Don't import classes/functions directly. | 48 # TODO(guido): Don't import classes/functions directly. |
| 49 from django import newforms as forms | 49 from django import newforms as forms |
| 50 from django.http import HttpResponse, HttpResponseRedirect | 50 from django.http import HttpResponse, HttpResponseRedirect |
| (...skipping 88 matching lines...) Show 10 above Show 10 below |
| 139 url = forms.URLField(required=False, | 139 url = forms.URLField(required=False, |
| 140 max_length=2083, | 140 max_length=2083, |
| 141 widget=forms.TextInput(attrs={'size': 60})) | 141 widget=forms.TextInput(attrs={'size': 60})) |
| 142 | 142 |
| 143 | 143 |
| 144 class UploadForm(forms.Form): | 144 class UploadForm(forms.Form): |
| 145 | 145 |
| 146 subject = forms.CharField(max_length=100) | 146 subject = forms.CharField(max_length=100) |
| 147 description = forms.CharField(max_length=10000, required=False) | 147 description = forms.CharField(max_length=10000, required=False) |
| 148 base = forms.CharField(max_length=2000) | 148 base = forms.CharField(max_length=2000) |
| 149 data = forms.FileField() | 149 data = forms.FileField() |
| 150 issue = forms.IntegerField(required=False) | 150 issue = forms.IntegerField(required=False) |
| 151 | 151 |
| 152 def get_base(self): | 152 def get_base(self): |
| 153 return self.cleaned_data.get('base') | 153 return self.cleaned_data.get('base') |
| 154 | 154 |
| 155 | 155 |
| 156 class EditForm(IssueBaseForm): | 156 class EditForm(IssueBaseForm): |
| 157 pass | 157 pass |
| 158 | 158 |
| 159 | 159 |
| 160 class RepoForm(djangoforms.ModelForm): | 160 class RepoForm(djangoforms.ModelForm): |
| 161 | 161 |
| 162 class Meta: | 162 class Meta: |
| 163 model = models.Repository | 163 model = models.Repository |
| 164 exclude = ['owner'] | 164 exclude = ['owner'] |
| 165 | 165 |
| 166 | 166 |
| 167 class BranchForm(djangoforms.ModelForm): | 167 class BranchForm(djangoforms.ModelForm): |
| 168 | 168 |
| 169 class Meta: | 169 class Meta: |
| 170 model = models.Branch | 170 model = models.Branch |
| 171 exclude = ['owner'] | 171 exclude = ['owner'] |
| 172 | 172 |
| 173 | 173 |
| 174 class PublishForm(forms.Form): | 174 class PublishForm(forms.Form): |
| 175 | 175 |
| 176 subject = forms.CharField(max_length=100, | 176 subject = forms.CharField(max_length=100, |
| 177 widget=forms.TextInput(attrs={'size': 60})) | 177 widget=forms.TextInput(attrs={'size': 60})) |
| 178 reviewers = forms.CharField(required=False, | 178 reviewers = forms.CharField(required=False, |
| 179 max_length=1000, | 179 max_length=1000, |
| 180 widget=forms.TextInput(attrs={'size': 60})) | 180 widget=forms.TextInput(attrs={'size': 60})) |
| 181 send_mail = forms.BooleanField() | 181 send_mail = forms.BooleanField() |
| 182 message = forms.CharField(required=False, | 182 message = forms.CharField(required=False, |
| 183 max_length=10000, | 183 max_length=10000, |
| 184 widget=forms.Textarea(attrs={'cols': 60})) | 184 widget=forms.Textarea(attrs={'cols': 60})) |
| 185 | 185 |
| 186 | 186 |
| 187 class MiniPublishForm(forms.Form): | 187 class MiniPublishForm(forms.Form): |
| 188 | 188 |
| 189 send_mail = forms.BooleanField() | |
| 190 reviewers = forms.CharField(required=False, | 189 reviewers = forms.CharField(required=False, |
| 191 max_length=1000, | 190 max_length=1000, |
| 192 widget=forms.TextInput(attrs={'size': 60})) | 191 widget=forms.TextInput(attrs={'size': 60})) |
| 192 send_mail = forms.BooleanField() |
| 193 message = forms.CharField(required=False, | 193 message = forms.CharField(required=False, |
| 194 max_length=10000, | 194 max_length=10000, |
| 195 widget=forms.Textarea(attrs={'cols': 60})) | 195 widget=forms.Textarea(attrs={'cols': 60})) |
| 196 | 196 |
| 197 | 197 |
| 198 class SettingsForm(forms.Form): | 198 class SettingsForm(forms.Form): |
| 199 | 199 |
| 200 nickname = forms.CharField(max_length=30) | 200 nickname = forms.CharField(max_length=30) |
| 201 | 201 |
| 202 | 202 |
| 203 ### Helper functions ### | 203 ### Helper functions ### |
| 204 | 204 |
| 205 | 205 |
| 206 # Counter displayed (by respond()) below) on every page showing how | 206 # Counter displayed (by respond()) below) on every page showing how |
| 207 # many requests the current incarnation has handled, not counting | 207 # many requests the current incarnation has handled, not counting |
| 208 # redirects. Rendered by templates/base.html. | 208 # redirects. Rendered by templates/base.html. |
| 209 counter = 0 | 209 counter = 0 |
| 210 | 210 |
| 211 def respond(request, template, params=None): | 211 def respond(request, template, params=None): |
| 212 """Helper to render a response, passing standard stuff to the response. | 212 """Helper to render a response, passing standard stuff to the response. |
| 213 | 213 |
| 214 Args: | 214 Args: |
| 215 request: The request object. | 215 request: The request object. |
| 216 template: The template name; '.html' is appended automatically. | 216 template: The template name; '.html' is appended automatically. |
| 217 params: A dict giving the template parameters; modified in-place. | 217 params: A dict giving the template parameters; modified in-place. |
| 218 | 218 |
| 219 Returns: | 219 Returns: |
| 220 Whatever render_to_response(template, params) returns. | 220 Whatever render_to_response(template, params) returns. |
| 221 | 221 |
| 222 Raises: | 222 Raises: |
| 223 Whatever render_to_response(template, params) raises. | 223 Whatever render_to_response(template, params) raises. |
| 224 """ | 224 """ |
| 225 global counter | 225 global counter |
| 226 counter += 1 | 226 counter += 1 |
| 227 if params is None: | 227 if params is None: |
| 228 params = {} | 228 params = {} |
| 229 must_choose_nickname = False | 229 must_choose_nickname = False |
| 230 if request.user is not None: | 230 if request.user is not None: |
| 231 account = models.Account.get_account_for_user(request.user) | 231 account = models.Account.get_account_for_user(request.user) |
| 232 delta = account.created - account.modified | 232 delta = account.created - account.modified |
| 233 if delta.days < 0: | 233 if delta.days < 0: |
| 234 delta = -delta | 234 delta = -delta |
| 235 must_choose_nickname = delta.days == 0 and delta.seconds < 2 | 235 must_choose_nickname = delta.days == 0 and delta.seconds < 2 |
| 236 params['request'] = request | 236 params['request'] = request |
| 237 params['counter'] = counter | 237 params['counter'] = counter |
| 238 params['user'] = request.user | 238 params['user'] = request.user |
| 239 params['is_admin'] = request.user_is_admin | 239 params['is_admin'] = request.user_is_admin |
| 240 params['is_dev'] = IS_DEV | 240 params['is_dev'] = IS_DEV |
| 241 params['sign_in'] = users.create_login_url(request.path) | 241 params['sign_in'] = users.create_login_url(request.path) |
| 242 params['sign_out'] = users.create_logout_url(request.path) | 242 params['sign_out'] = users.create_logout_url(request.path) |
| (...skipping 720 matching lines...) Show 10 above Show 10 below |
| 963 tbd = [] # List of things to put() after all is said and done | 963 tbd = [] # List of things to put() after all is said and done |
| 964 if request.user == issue.owner: | 964 if request.user == issue.owner: |
| 965 subject = form.cleaned_data['subject'] | 965 subject = form.cleaned_data['subject'] |
| 966 issue.subject = subject | 966 issue.subject = subject |
| 967 issue.reviewers = reviewers | 967 issue.reviewers = reviewers |
| 968 else: | 968 else: |
| 969 subject = issue.subject | 969 subject = issue.subject |
| 970 issue.reviewers = reviewers | 970 issue.reviewers = reviewers |
| 971 tbd.append(issue) # To update the last modified time | 971 tbd.append(issue) # To update the last modified time |
| 972 message = form.cleaned_data['message'].replace('\r\n', '\n') | 972 message = form.cleaned_data['message'].replace('\r\n', '\n') |
| 973 send_mail = form.cleaned_data['send_mail'] | 973 send_mail = form.cleaned_data['send_mail'] |
| 974 comments = [] | 974 comments = [] |
| 975 | 975 |
| 976 # XXX Should request all drafts for this issue once, now we can. | 976 # XXX Should request all drafts for this issue once, now we can. |
| 977 for patchset in issue.patchset_set.order('created'): | 977 for patchset in issue.patchset_set.order('created'): |
| 978 ## ps_comments = list(models.Comment.gql( | 978 ## ps_comments = list(models.Comment.gql( |
| 979 ## 'WHERE ANCESTOR IS :1 AND author = :2 AND draft = TRUE', | 979 ## 'WHERE ANCESTOR IS :1 AND author = :2 AND draft = TRUE', |
| 980 ## patchset, request.user)) | 980 ## patchset, request.user)) |
| 981 # XXX Somehow the index broke, do without it | 981 # XXX Somehow the index broke, do without it |
| 982 ps_comments = [c for c in | 982 ps_comments = [c for c in |
| 983 models.Comment.gql('WHERE ANCESTOR IS :1', patchset) | 983 models.Comment.gql('WHERE ANCESTOR IS :1', patchset) |
| 984 if c.draft and c.author == request.user] | 984 if c.draft and c.author == request.user] |
| 985 # XXX End | 985 # XXX End |
| 986 if ps_comments: | 986 if ps_comments: |
| 987 patches = dict((p.key(), p) for p in patchset.patch_set) | 987 patches = dict((p.key(), p) for p in patchset.patch_set) |
| 988 for p in patches.itervalues(): | 988 for p in patches.itervalues(): |
| 989 p.patchset = patchset | 989 p.patchset = patchset |
| 990 for c in ps_comments: | 990 for c in ps_comments: |
| 991 c.draft = False | 991 c.draft = False |
| 992 # XXX Using internal knowledge about db package: the key for | 992 # XXX Using internal knowledge about db package: the key for |
| 993 # reference property foo is stored as _foo. | 993 # reference property foo is stored as _foo. |
| 994 pkey = getattr(c, '_patch', None) | 994 pkey = getattr(c, '_patch', None) |
| 995 if pkey in patches: | 995 if pkey in patches: |
| 996 patch = patches[pkey] | 996 patch = patches[pkey] |
| 997 c.patch = patch | 997 c.patch = patch |
| 998 tbd.append(ps_comments) | 998 tbd.append(ps_comments) |
| 999 ps_comments.sort(key=lambda c: (c.patch.filename, not c.left, | 999 ps_comments.sort(key=lambda c: (c.patch.filename, not c.left, |
| 1000 c.lineno, c.date)) | 1000 c.lineno, c.date)) |
| 1001 comments += ps_comments | 1001 comments += ps_comments |
| 1002 | 1002 |
| 1003 if comments: | 1003 if comments: |
| 1004 logging.warn('Publishing %d comments', len(comments)) | 1004 logging.warn('Publishing %d comments', len(comments)) |
| 1005 # Decide who should receive mail | 1005 # Decide who should receive mail |
| 1006 my_email = db.Email(request.user.email()) | 1006 my_email = db.Email(request.user.email()) |
| 1007 addressees = [db.Email(issue.owner.email())] + issue.reviewers | 1007 addressees = [db.Email(issue.owner.email())] + issue.reviewers |
| 1008 if my_email in addressees: | 1008 if my_email in addressees: |
| 1009 everyone = addressees[:] | 1009 everyone = addressees[:] |
| 1010 if len(addressees) > 1: # Keep it if sending only to yourself | 1010 if len(addressees) > 1: # Keep it if sending only to yourself |
| 1011 addressees.remove(my_email) | 1011 addressees.remove(my_email) |
| 1012 else: | 1012 else: |
| 1013 everyone = addressees + [my_email] | 1013 everyone = addressees |
| 1014 details = _get_draft_details(request, comments) | 1014 details = _get_draft_details(request, comments) |
| 1015 text = ((message.strip() + '\n\n' + details.strip())).strip() | 1015 text = ((message.strip() + '\n\n' + details.strip())).strip() |
| 1016 msg = models.Message(issue=issue, | 1016 msg = models.Message(issue=issue, |
| 1017 subject=issue.subject, | 1017 subject=issue.subject, |
| 1018 sender=my_email, | 1018 sender=my_email, |
| 1019 recipients=everyone, | 1019 recipients=everyone, |
| 1020 text=db.Text(text), | 1020 text=db.Text(text), |
| 1021 parent=issue) | 1021 parent=issue) |
| 1022 tbd.append(msg) | 1022 tbd.append(msg) |
| 1023 | 1023 |
| 1024 if send_mail: | 1024 if send_mail: |
| 1025 url = request.build_absolute_uri('/%s' % issue.key().id()) | 1025 url = request.build_absolute_uri('/%s' % issue.key().id()) |
| 1026 addressees_nicknames = ", ".join(library.nickname(addressee, True) | 1026 addressees_nicknames = ", ".join(library.nickname(addressee, True) |
| 1027 for addressee in addressees) | 1027 for addressee in addressees) |
| 1028 my_nickname = library.nickname(request.user, True) | 1028 my_nickname = library.nickname(request.user, True) |
| 1029 addressees = ', '.join(addressees) | 1029 addressees = ', '.join(addressees) |
| 1030 description = (issue.description or '').replace('\r\n', '\n') | 1030 description = (issue.description or '').replace('\r\n', '\n') |
| 1031 home = request.build_absolute_uri('/') | 1031 home = request.build_absolute_uri('/') |
| 1032 body = PUBLISH_MAIL_TEMPLATE % (addressees_nicknames, my_nickname, | 1032 body = PUBLISH_MAIL_TEMPLATE % (addressees_nicknames, my_nickname, |
| 1033 url, message, | 1033 url, message, |
| 1034 details, description, home) | 1034 details, description, home) |
| 1035 logging.warn('Mail: to=%s; cc=%s', addressees, my_email) | 1035 logging.warn('Mail: to=%s; cc=%s', addressees, my_email) |
| 1036 mail.send_mail(sender=SENDER, | 1036 mail.send_mail(sender=SENDER, |
| 1037 to=_encode_safely(addressees), | 1037 to=_encode_safely(addressees), |
| 1038 subject=_encode_safely('Re: ' + subject), | 1038 subject=_encode_safely('Re: ' + subject), |
| 1039 body=_encode_safely(body), | 1039 body=_encode_safely(body), |
| 1040 cc=_encode_safely(my_email), | 1040 cc=_encode_safely(my_email), |
| 1041 reply_to=_encode_safely(', '.join(everyone))) | 1041 reply_to=_encode_safely(', '.join(everyone))) |
| 1042 | 1042 |
| 1043 for obj in tbd: | 1043 for obj in tbd: |
| 1044 db.put(obj) | 1044 db.put(obj) |
| 1045 return HttpResponseRedirect('/%s' % issue.key().id()) | 1045 return HttpResponseRedirect('/%s' % issue.key().id()) |
| 1046 | 1046 |
| 1047 | 1047 |
| 1048 def _encode_safely(s): | 1048 def _encode_safely(s): |
| 1049 """Helper to turn a unicode string into 8-bit bytes.""" | 1049 """Helper to turn a unicode string into 8-bit bytes.""" |
| 1050 if isinstance(s, unicode): | 1050 if isinstance(s, unicode): |
| 1051 s = s.encode('utf-8') | 1051 s = s.encode('utf-8') |
| 1052 return s | 1052 return s |
| 1053 | 1053 |
| 1054 | 1054 |
| 1055 def _get_draft_details(request, comments): | 1055 def _get_draft_details(request, comments): |
| 1056 """Helper to display comments with context in the email message.""" | 1056 """Helper to display comments with context in the email message.""" |
| 1057 last_key = None | 1057 last_key = None |
| 1058 output = [] | 1058 output = [] |
| 1059 linecache = {} # Maps (c.patch.filename, c.left) to list of lines | 1059 linecache = {} # Maps (c.patch.filename, c.left) to list of lines |
| 1060 modified_patches = [] | 1060 modified_patches = [] |
| 1061 for c in comments: | 1061 for c in comments: |
| 1062 if (c.patch.filename, c.left) != last_key: | 1062 if (c.patch.filename, c.left) != last_key: |
| 1063 url = request.build_absolute_uri('/%d/diff/%d/%d' % | 1063 url = request.build_absolute_uri('/%d/diff/%d/%d' % |
| (...skipping 140 matching lines...) Show 10 above Show 10 below |
| 1204 return respond(request, 'branch_edit.html', | 1204 return respond(request, 'branch_edit.html', |
| 1205 {'branch': branch, 'form': form}) | 1205 {'branch': branch, 'form': form}) |
| 1206 branch.put() | 1206 branch.put() |
| 1207 return HttpResponseRedirect('/repos') | 1207 return HttpResponseRedirect('/repos') |
| 1208 | 1208 |
| 1209 | 1209 |
| 1210 @login_required | 1210 @login_required |
| 1211 def branch_delete(request, branch_id): | 1211 def branch_delete(request, branch_id): |
| 1212 """/branch_delete/<branch> - Delete a Branch record.""" | 1212 """/branch_delete/<branch> - Delete a Branch record.""" |
| 1213 branch = models.Branch.get_by_id(int(branch_id)) | 1213 branch = models.Branch.get_by_id(int(branch_id)) |
| 1214 if branch.owner != request.user: | 1214 if branch.owner != request.user: |
| 1215 return HttpResponseForbidden('You do not own this branch') | 1215 return HttpResponseForbidden('You do not own this branch') |
| 1216 repo = branch.repo | 1216 repo = branch.repo |
| 1217 branch.delete() | 1217 branch.delete() |
| 1218 num_branches = models.Branch.gql('WHERE repo = :1', repo).count() | 1218 num_branches = models.Branch.gql('WHERE repo = :1', repo).count() |
| 1219 if not num_branches: | 1219 if not num_branches: |
| 1220 # Even if we don't own the repository? Yes, I think so! Empty | 1220 # Even if we don't own the repository? Yes, I think so! Empty |
| 1221 # repositories have no representation on screen. | 1221 # repositories have no representation on screen. |
| 1222 repo.delete() | 1222 repo.delete() |
| 1223 return HttpResponseRedirect('/repos') | 1223 return HttpResponseRedirect('/repos') |
| 1224 | 1224 |
| 1225 | 1225 |
| 1226 ### User Profiles ### | 1226 ### User Profiles ### |
| 1227 | 1227 |
| 1228 @login_required | 1228 @login_required |
| 1229 def settings(request): | 1229 def settings(request): |
| 1230 account = models.Account.get_account_for_user(request.user) | 1230 account = models.Account.get_account_for_user(request.user) |
| 1231 if request.method != 'POST': | 1231 if request.method != 'POST': |
| 1232 nickname = account.nickname | 1232 nickname = account.nickname |
| 1233 form = SettingsForm(initial={'nickname': nickname}) | 1233 form = SettingsForm(initial={'nickname': nickname}) |
| 1234 return respond(request, 'settings.html', {'form': form}) | 1234 return respond(request, 'settings.html', {'form': form}) |
| 1235 form = SettingsForm(request.POST) | 1235 form = SettingsForm(request.POST) |
| 1236 if form.is_valid(): | 1236 if form.is_valid(): |
| 1237 nickname = form.cleaned_data['nickname'].strip() | 1237 nickname = form.cleaned_data['nickname'].strip() |
| 1238 if not nickname: | 1238 if not nickname: |
| 1239 form.errors['nickname'] = ['Your nickname cannot be empty.'] | 1239 form.errors['nickname'] = ['Your nickname cannot be empty.'] |
| 1240 elif '@' in nickname: | 1240 elif '@' in nickname: |
| 1241 form.errors['nickname'] = ['Your nickname cannot contain "@".'] | 1241 form.errors['nickname'] = ['Your nickname cannot contain "@".'] |
| 1242 elif ',' in nickname: | 1242 elif ',' in nickname: |
| 1243 form.errors['nickname'] = ['Your nickname cannot contain ",".'] | 1243 form.errors['nickname'] = ['Your nickname cannot contain ",".'] |
| 1244 else: | 1244 else: |
| 1245 accounts = models.Account.get_accounts_for_nickname(nickname) | 1245 accounts = models.Account.get_accounts_for_nickname(nickname) |
| 1246 if nickname != account.nickname and accounts: | 1246 if nickname != account.nickname and accounts: |
| 1247 form.errors['nickname'] = ['This nickname is already in use.'] | 1247 form.errors['nickname'] = ['This nickname is already in use.'] |
| 1248 else: | 1248 else: |
| 1249 account.nickname = nickname | 1249 account.nickname = nickname |
| 1250 account.put() | 1250 account.put() |
| 1251 if not form.is_valid(): | 1251 if not form.is_valid(): |
| 1252 return respond(request, 'settings.html', {'form': form}) | 1252 return respond(request, 'settings.html', {'form': form}) |
| 1253 return HttpResponseRedirect('/settings') | 1253 return HttpResponseRedirect('/settings') |
| LEFT | RIGHT |