Skip to content

Commit a08c8dc

Browse files
committed
Added an API endpoint to support automation of IESG ballot position posting, at /api/iesg/position. Added tests for the API endpoint, and updated the apikey validation decorator tests. Tweaked the decorator to handle a weakness found during testing.
- Legacy-Id: 14429
1 parent b0863c8 commit a08c8dc

4 files changed

Lines changed: 166 additions & 76 deletions

File tree

ietf/doc/tests_ballot.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from ietf.group.models import Group, Role
1313
from ietf.name.models import BallotPositionName
1414
from ietf.iesg.models import TelechatDate
15-
from ietf.person.models import Person
15+
from ietf.person.models import Person, PersonalApiKey
1616
from ietf.utils.test_utils import TestCase, unicontent
1717
from ietf.utils.mail import outbox, empty_outbox
1818
from ietf.utils.test_data import make_test_data
@@ -82,6 +82,65 @@ def test_edit_position(self):
8282
self.assertEqual(draft.docevent_set.count(), events_before + 2)
8383
self.assertTrue("Ballot comment text updated" in pos.desc)
8484

85+
def test_api_set_position(self):
86+
draft = make_test_data()
87+
url = urlreverse('ietf.doc.views_ballot.api_set_position')
88+
ad = Person.objects.get(name="Areað Irector")
89+
ad.user.last_login = datetime.datetime.now()
90+
ad.user.save()
91+
apikey = PersonalApiKey.objects.create(endpoint=url, person=ad)
92+
93+
# vote
94+
events_before = draft.docevent_set.count()
95+
96+
r = self.client.post(url, dict(
97+
apikey=apikey.hash(),
98+
doc=draft.name,
99+
position="discuss",
100+
discuss=" This is a discussion test. \n ",
101+
comment=" This is a test. \n ")
102+
)
103+
self.assertEqual(r.status_code, 200)
104+
105+
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
106+
self.assertEqual(pos.pos.slug, "discuss")
107+
self.assertTrue(" This is a discussion test." in pos.discuss)
108+
self.assertTrue(pos.discuss_time != None)
109+
self.assertTrue(" This is a test." in pos.comment)
110+
self.assertTrue(pos.comment_time != None)
111+
self.assertTrue("New position" in pos.desc)
112+
self.assertEqual(draft.docevent_set.count(), events_before + 3)
113+
114+
# recast vote
115+
events_before = draft.docevent_set.count()
116+
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="noobj"))
117+
self.assertEqual(r.status_code, 200)
118+
119+
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
120+
self.assertEqual(pos.pos.slug, "noobj")
121+
self.assertEqual(draft.docevent_set.count(), events_before + 1)
122+
self.assertTrue("Position for" in pos.desc)
123+
124+
# clear vote
125+
events_before = draft.docevent_set.count()
126+
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="norecord"))
127+
self.assertEqual(r.status_code, 200)
128+
129+
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
130+
self.assertEqual(pos.pos.slug, "norecord")
131+
self.assertEqual(draft.docevent_set.count(), events_before + 1)
132+
self.assertTrue("Position for" in pos.desc)
133+
134+
# change comment
135+
events_before = draft.docevent_set.count()
136+
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="norecord", comment="New comment."))
137+
self.assertEqual(r.status_code, 200)
138+
139+
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
140+
self.assertEqual(pos.pos.slug, "norecord")
141+
self.assertEqual(draft.docevent_set.count(), events_before + 2)
142+
self.assertTrue("Ballot comment text updated" in pos.desc)
143+
85144
def test_edit_position_as_secretary(self):
86145
draft = make_test_data()
87146
url = urlreverse('ietf.doc.views_ballot.edit_position', kwargs=dict(name=draft.name,

ietf/doc/views_ballot.py

Lines changed: 100 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.conf import settings
88
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, Http404
99
from django.shortcuts import render, get_object_or_404, redirect
10+
from django.template.defaultfilters import striptags
1011
from django.template.loader import render_to_string
1112
from django.urls import reverse as urlreverse
1213
from django.views.decorators.csrf import csrf_exempt
@@ -108,6 +109,74 @@ def clean_discuss(self):
108109
raise forms.ValidationError("You must enter a non-empty discuss")
109110
return entered_discuss
110111

112+
def save_position(form, doc, ballot, ad, login=None):
113+
# save the vote
114+
if login is None:
115+
login = ad
116+
clean = form.cleaned_data
117+
118+
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
119+
pos = BallotPositionDocEvent(doc=doc, rev=doc.rev, by=login)
120+
pos.type = "changed_ballot_position"
121+
pos.ballot = ballot
122+
pos.ad = ad
123+
pos.pos = clean["position"]
124+
pos.comment = clean["comment"].rstrip()
125+
pos.comment_time = old_pos.comment_time if old_pos else None
126+
pos.discuss = clean["discuss"].rstrip()
127+
if not pos.pos.blocking:
128+
pos.discuss = ""
129+
pos.discuss_time = old_pos.discuss_time if old_pos else None
130+
131+
changes = []
132+
added_events = []
133+
# possibly add discuss/comment comments to history trail
134+
# so it's easy to see what's happened
135+
old_comment = old_pos.comment if old_pos else ""
136+
if pos.comment != old_comment:
137+
pos.comment_time = pos.time
138+
changes.append("comment")
139+
140+
if pos.comment:
141+
e = DocEvent(doc=doc, rev=doc.rev)
142+
e.by = ad # otherwise we can't see who's saying it
143+
e.type = "added_comment"
144+
e.desc = "[Ballot comment]\n" + pos.comment
145+
added_events.append(e)
146+
147+
old_discuss = old_pos.discuss if old_pos else ""
148+
if pos.discuss != old_discuss:
149+
pos.discuss_time = pos.time
150+
changes.append("discuss")
151+
152+
if pos.pos.blocking:
153+
e = DocEvent(doc=doc, rev=doc.rev, by=login)
154+
e.by = ad # otherwise we can't see who's saying it
155+
e.type = "added_comment"
156+
e.desc = "[Ballot %s]\n" % pos.pos.name.lower()
157+
e.desc += pos.discuss
158+
added_events.append(e)
159+
160+
# figure out a description
161+
if not old_pos and pos.pos.slug != "norecord":
162+
pos.desc = u"[Ballot Position Update] New position, %s, has been recorded for %s" % (pos.pos.name, pos.ad.plain_name())
163+
elif old_pos and pos.pos != old_pos.pos:
164+
pos.desc = "[Ballot Position Update] Position for %s has been changed to %s from %s" % (pos.ad.plain_name(), pos.pos.name, old_pos.pos.name)
165+
166+
if not pos.desc and changes:
167+
pos.desc = u"Ballot %s text updated for %s" % (u" and ".join(changes), ad.plain_name())
168+
169+
# only add new event if we actually got a change
170+
if pos.desc:
171+
if login != ad:
172+
pos.desc += u" by %s" % login.plain_name()
173+
174+
pos.save()
175+
176+
for e in added_events:
177+
e.save() # save them after the position is saved to get later id for sorting order
178+
179+
111180
@role_required('Area Director','Secretariat')
112181
def edit_position(request, name, ballot_id):
113182
"""Vote and edit discuss and comment on document as Area Director."""
@@ -128,77 +197,14 @@ def edit_position(request, name, ballot_id):
128197
raise Http404
129198
ad = get_object_or_404(Person, pk=ad_id)
130199

131-
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
132-
133200
if request.method == 'POST':
134201
if not has_role(request.user, "Secretariat") and not ad.role_set.filter(name="ad", group__type="area", group__state="active"):
135202
# prevent pre-ADs from voting
136203
return HttpResponseForbidden("Must be a proper Area Director in an active area to cast ballot")
137204

138205
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
139206
if form.is_valid():
140-
# save the vote
141-
clean = form.cleaned_data
142-
143-
pos = BallotPositionDocEvent(doc=doc, rev=doc.rev, by=login)
144-
pos.type = "changed_ballot_position"
145-
pos.ballot = ballot
146-
pos.ad = ad
147-
pos.pos = clean["position"]
148-
pos.comment = clean["comment"].rstrip()
149-
pos.comment_time = old_pos.comment_time if old_pos else None
150-
pos.discuss = clean["discuss"].rstrip()
151-
if not pos.pos.blocking:
152-
pos.discuss = ""
153-
pos.discuss_time = old_pos.discuss_time if old_pos else None
154-
155-
changes = []
156-
added_events = []
157-
# possibly add discuss/comment comments to history trail
158-
# so it's easy to see what's happened
159-
old_comment = old_pos.comment if old_pos else ""
160-
if pos.comment != old_comment:
161-
pos.comment_time = pos.time
162-
changes.append("comment")
163-
164-
if pos.comment:
165-
e = DocEvent(doc=doc, rev=doc.rev)
166-
e.by = ad # otherwise we can't see who's saying it
167-
e.type = "added_comment"
168-
e.desc = "[Ballot comment]\n" + pos.comment
169-
added_events.append(e)
170-
171-
old_discuss = old_pos.discuss if old_pos else ""
172-
if pos.discuss != old_discuss:
173-
pos.discuss_time = pos.time
174-
changes.append("discuss")
175-
176-
if pos.pos.blocking:
177-
e = DocEvent(doc=doc, rev=doc.rev, by=login)
178-
e.by = ad # otherwise we can't see who's saying it
179-
e.type = "added_comment"
180-
e.desc = "[Ballot %s]\n" % pos.pos.name.lower()
181-
e.desc += pos.discuss
182-
added_events.append(e)
183-
184-
# figure out a description
185-
if not old_pos and pos.pos.slug != "norecord":
186-
pos.desc = u"[Ballot Position Update] New position, %s, has been recorded for %s" % (pos.pos.name, pos.ad.plain_name())
187-
elif old_pos and pos.pos != old_pos.pos:
188-
pos.desc = "[Ballot Position Update] Position for %s has been changed to %s from %s" % (pos.ad.plain_name(), pos.pos.name, old_pos.pos.name)
189-
190-
if not pos.desc and changes:
191-
pos.desc = u"Ballot %s text updated for %s" % (u" and ".join(changes), ad.plain_name())
192-
193-
# only add new event if we actually got a change
194-
if pos.desc:
195-
if login != ad:
196-
pos.desc += u" by %s" % login.plain_name()
197-
198-
pos.save()
199-
200-
for e in added_events:
201-
e.save() # save them after the position is saved to get later id for sorting order
207+
save_position(form, doc, ballot, ad, login)
202208

203209
if request.POST.get("send_mail"):
204210
qstr=""
@@ -213,6 +219,7 @@ def edit_position(request, name, ballot_id):
213219
return HttpResponseRedirect(return_to_url)
214220
else:
215221
initial = {}
222+
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
216223
if old_pos:
217224
initial['position'] = old_pos.pos.slug
218225
initial['discuss'] = old_pos.discuss
@@ -240,10 +247,37 @@ def edit_position(request, name, ballot_id):
240247
@role_required('Area Director', 'Secretariat')
241248
@csrf_exempt
242249
def api_set_position(request):
250+
def err(code, text):
251+
return HttpResponse(text, status=code, content_type='text/plain')
243252
if request.method == 'POST':
244-
pass
253+
ad = request.user.person
254+
name = request.POST.get('doc')
255+
if not name:
256+
return err(400, "Missing document name")
257+
try:
258+
doc = Document.objects.get(docalias__name=name)
259+
except Document.DoesNotExist:
260+
return err(404, "Document not found")
261+
position_names = BallotPositionName.objects.values_list('slug', flat=True)
262+
position = request.POST.get('position')
263+
if not position:
264+
return err(400, "Missing parameter: position, one of: %s " % ','.join(position_names))
265+
if not position in position_names:
266+
return err(400, "Bad position name, must be one of: %s " % ','.join(position_names))
267+
ballot = doc.active_ballot()
268+
if not ballot:
269+
return err(404, "No open ballot found")
270+
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
271+
if form.is_valid():
272+
save_position(form, doc, ballot, ad)
273+
else:
274+
debug.type('form.errors')
275+
debug.show('form.errors')
276+
errors = form.errors
277+
summary = ','.join([ "%s: %s" % (f, striptags(errors[f])) for f in errors ])
278+
return err(400, "Form not valid: %s" % summary)
245279
else:
246-
return HttpResponse("Method not allowed", status=405, content_type='text/plain')
280+
return err(405, "Method not allowed")
247281

248282
return HttpResponse("Done", status=200, content_type='text/plain')
249283

ietf/ietfauth/tests.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
# Copyright The IETF Trust 2017, All Rights Reserved
12
# -*- coding: utf-8 -*-
2-
from __future__ import unicode_literals
3+
from __future__ import unicode_literals, print_function
34

45
import os, shutil, time, datetime
56
from urlparse import urlsplit
@@ -101,7 +102,7 @@ def username_in_htpasswd_file(self, username):
101102
if l.startswith(username + ":"):
102103
return True
103104
with open(settings.HTPASSWD_FILE) as f:
104-
print f.read()
105+
print(f.read())
105106

106107
return False
107108

@@ -552,7 +553,7 @@ def test_apikey_management(self):
552553
self.assertEqual(len(q('td code')), len(PERSON_API_KEY_ENDPOINTS)) # key hash
553554
self.assertEqual(len(q('td a:contains("Disable")')), len(PERSON_API_KEY_ENDPOINTS)-1)
554555

555-
def test_apikey_usage(self):
556+
def test_apikey_errors(self):
556557
BAD_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
557558

558559
person = PersonFactory()
@@ -571,10 +572,6 @@ def test_apikey_usage(self):
571572
for key in person.apikeys.all()[:3]:
572573
url = key.endpoint
573574

574-
# successful access
575-
r = self.client.post(url, {'apikey':key.hash(), 'dummy':'dummy',})
576-
self.assertEqual(r.status_code, 200)
577-
578575
# bad method
579576
r = self.client.put(url, {'apikey':key.hash()})
580577
self.assertEqual(r.status_code, 405)
@@ -638,7 +635,7 @@ def test_send_apikey_report(self):
638635

639636
self.assertEqual(len(outbox), len(PERSON_API_KEY_ENDPOINTS))
640637
for mail in outbox:
641-
body = mail.get_payload()
638+
body = mail.get_payload(decode=True).decode('utf-8')
642639
self.assertIn("API key usage", mail['subject'])
643640
self.assertIn(" %s times" % count, body)
644641
self.assertIn(date, body)

ietf/utils/decorators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def err(code, text):
6060
person = key.person
6161
last_login = person.user.last_login
6262
time_limit = (datetime.datetime.now() - datetime.timedelta(days=settings.UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS))
63-
if last_login < time_limit:
63+
if last_login == None or last_login < time_limit:
6464
return err(400, "Too long since last regular login")
6565
# Log in
6666
login(request, person.user)

0 commit comments

Comments
 (0)