Merged in ^/personal/henrik/6.64.2-ballotapi@14426. This provides personal API keys and a ballot position API at /api/iesg/position. Also added an endpoint description at /api/.
- Legacy-Id: 14430
This commit is contained in:
commit
6567e707ce
|
@ -3,8 +3,7 @@
|
||||||
# Nightly datatracker jobs.
|
# Nightly datatracker jobs.
|
||||||
#
|
#
|
||||||
# This script is expected to be triggered by cron from
|
# This script is expected to be triggered by cron from
|
||||||
# $DTDIR/etc/cron.d/datatracker which should be symlinked from
|
# /etc/cron.d/datatracker
|
||||||
# /etc/cron.d/
|
|
||||||
|
|
||||||
# Run the hourly jobs first
|
# Run the hourly jobs first
|
||||||
$DTDIR/bin/hourly
|
$DTDIR/bin/hourly
|
||||||
|
|
|
@ -3,8 +3,7 @@
|
||||||
# Hourly datatracker jobs
|
# Hourly datatracker jobs
|
||||||
#
|
#
|
||||||
# This script is expected to be triggered by cron from
|
# This script is expected to be triggered by cron from
|
||||||
# $DTDIR/etc/cron.d/datatracker which should be symlinked from
|
# /etc/cron.d/datatracker
|
||||||
# /etc/cron.d/
|
|
||||||
|
|
||||||
DTDIR=/a/www/ietf-datatracker/web
|
DTDIR=/a/www/ietf-datatracker/web
|
||||||
cd $DTDIR/
|
cd $DTDIR/
|
||||||
|
|
15
bin/monthly
Executable file
15
bin/monthly
Executable file
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Weekly datatracker jobs.
|
||||||
|
#
|
||||||
|
# This script is expected to be triggered by cron from
|
||||||
|
# /etc/cron.d/datatracker
|
||||||
|
|
||||||
|
DTDIR=/a/www/ietf-datatracker/web
|
||||||
|
cd $DTDIR/
|
||||||
|
|
||||||
|
# Set up the virtual environment
|
||||||
|
source $DTDIR/env/bin/activate
|
||||||
|
|
||||||
|
logger -p user.info -t cron "Running $DTDIR/bin/monthly"
|
||||||
|
|
20
bin/weekly
Executable file
20
bin/weekly
Executable file
|
@ -0,0 +1,20 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Weekly datatracker jobs.
|
||||||
|
#
|
||||||
|
# This script is expected to be triggered by cron from
|
||||||
|
# /etc/cron.d/datatracker
|
||||||
|
|
||||||
|
DTDIR=/a/www/ietf-datatracker/web
|
||||||
|
cd $DTDIR/
|
||||||
|
|
||||||
|
# Set up the virtual environment
|
||||||
|
source $DTDIR/env/bin/activate
|
||||||
|
|
||||||
|
logger -p user.info -t cron "Running $DTDIR/bin/weekly"
|
||||||
|
|
||||||
|
|
||||||
|
# Send out weekly summaries of apikey usage
|
||||||
|
|
||||||
|
$DTDIR/ietf/manage.py send_apikey_usage_emails
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.conf.urls import include
|
||||||
|
|
||||||
from ietf import api
|
from ietf import api
|
||||||
from ietf.api import views as api_views
|
from ietf.api import views as api_views
|
||||||
|
from ietf.doc import views_ballot
|
||||||
from ietf.meeting import views as meeting_views
|
from ietf.meeting import views as meeting_views
|
||||||
from ietf.submit import views as submit_views
|
from ietf.submit import views as submit_views
|
||||||
from ietf.utils.urls import url
|
from ietf.utils.urls import url
|
||||||
|
@ -17,6 +18,7 @@ urlpatterns = [
|
||||||
# Custom API endpoints
|
# Custom API endpoints
|
||||||
url(r'^notify/meeting/import_recordings/(?P<number>[a-z0-9-]+)/?$', meeting_views.api_import_recordings),
|
url(r'^notify/meeting/import_recordings/(?P<number>[a-z0-9-]+)/?$', meeting_views.api_import_recordings),
|
||||||
url(r'^submit/?$', submit_views.api_submit),
|
url(r'^submit/?$', submit_views.api_submit),
|
||||||
|
url(r'^iesg/position', views_ballot.api_set_position),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Additional (standard) Tastypie endpoints
|
# Additional (standard) Tastypie endpoints
|
||||||
|
|
|
@ -13,7 +13,7 @@ from ietf.doc.utils import create_ballot_if_not_open
|
||||||
from ietf.group.models import Group, Role
|
from ietf.group.models import Group, Role
|
||||||
from ietf.name.models import BallotPositionName
|
from ietf.name.models import BallotPositionName
|
||||||
from ietf.iesg.models import TelechatDate
|
from ietf.iesg.models import TelechatDate
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person, PersonalApiKey
|
||||||
from ietf.utils.test_utils import TestCase, unicontent
|
from ietf.utils.test_utils import TestCase, unicontent
|
||||||
from ietf.utils.mail import outbox, empty_outbox
|
from ietf.utils.mail import outbox, empty_outbox
|
||||||
from ietf.utils.test_data import make_test_data
|
from ietf.utils.test_data import make_test_data
|
||||||
|
@ -85,6 +85,67 @@ class EditPositionTests(TestCase):
|
||||||
self.assertEqual(draft.docevent_set.count(), events_before + 2)
|
self.assertEqual(draft.docevent_set.count(), events_before + 2)
|
||||||
self.assertTrue("Ballot comment text updated" in pos.desc)
|
self.assertTrue("Ballot comment text updated" in pos.desc)
|
||||||
|
|
||||||
|
def test_api_set_position(self):
|
||||||
|
draft = make_test_data()
|
||||||
|
url = urlreverse('ietf.doc.views_ballot.api_set_position')
|
||||||
|
ad = Person.objects.get(name="Areað Irector")
|
||||||
|
create_ballot_if_not_open(None, draft, ad, 'approve')
|
||||||
|
ad.user.last_login = datetime.datetime.now()
|
||||||
|
ad.user.save()
|
||||||
|
apikey = PersonalApiKey.objects.create(endpoint=url, person=ad)
|
||||||
|
|
||||||
|
# vote
|
||||||
|
events_before = draft.docevent_set.count()
|
||||||
|
|
||||||
|
r = self.client.post(url, dict(
|
||||||
|
apikey=apikey.hash(),
|
||||||
|
doc=draft.name,
|
||||||
|
position="discuss",
|
||||||
|
discuss=" This is a discussion test. \n ",
|
||||||
|
comment=" This is a test. \n ")
|
||||||
|
)
|
||||||
|
self.assertEqual(r.content, "Done")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
|
||||||
|
self.assertEqual(pos.pos.slug, "discuss")
|
||||||
|
self.assertTrue(" This is a discussion test." in pos.discuss)
|
||||||
|
self.assertTrue(pos.discuss_time != None)
|
||||||
|
self.assertTrue(" This is a test." in pos.comment)
|
||||||
|
self.assertTrue(pos.comment_time != None)
|
||||||
|
self.assertTrue("New position" in pos.desc)
|
||||||
|
self.assertEqual(draft.docevent_set.count(), events_before + 3)
|
||||||
|
|
||||||
|
# recast vote
|
||||||
|
events_before = draft.docevent_set.count()
|
||||||
|
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="noobj"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
|
||||||
|
self.assertEqual(pos.pos.slug, "noobj")
|
||||||
|
self.assertEqual(draft.docevent_set.count(), events_before + 1)
|
||||||
|
self.assertTrue("Position for" in pos.desc)
|
||||||
|
|
||||||
|
# clear vote
|
||||||
|
events_before = draft.docevent_set.count()
|
||||||
|
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="norecord"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
|
||||||
|
self.assertEqual(pos.pos.slug, "norecord")
|
||||||
|
self.assertEqual(draft.docevent_set.count(), events_before + 1)
|
||||||
|
self.assertTrue("Position for" in pos.desc)
|
||||||
|
|
||||||
|
# change comment
|
||||||
|
events_before = draft.docevent_set.count()
|
||||||
|
r = self.client.post(url, dict(apikey=apikey.hash(), doc=draft.name, position="norecord", comment="New comment."))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
pos = draft.latest_event(BallotPositionDocEvent, ad=ad)
|
||||||
|
self.assertEqual(pos.pos.slug, "norecord")
|
||||||
|
self.assertEqual(draft.docevent_set.count(), events_before + 2)
|
||||||
|
self.assertTrue("Ballot comment text updated" in pos.desc)
|
||||||
|
|
||||||
def test_edit_position_as_secretary(self):
|
def test_edit_position_as_secretary(self):
|
||||||
draft = make_test_data()
|
draft = make_test_data()
|
||||||
ad = Person.objects.get(user__username="ad")
|
ad = Person.objects.get(user__username="ad")
|
||||||
|
|
|
@ -3,12 +3,15 @@
|
||||||
|
|
||||||
import datetime, json
|
import datetime, json
|
||||||
|
|
||||||
from django.http import HttpResponseForbidden, HttpResponseRedirect, Http404
|
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
|
||||||
from django.urls import reverse as urlreverse
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, Http404
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.template.defaultfilters import striptags
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse as urlreverse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
import debug # pyflakes:ignore
|
import debug # pyflakes:ignore
|
||||||
|
|
||||||
|
@ -30,6 +33,7 @@ from ietf.name.models import BallotPositionName
|
||||||
from ietf.person.models import Person
|
from ietf.person.models import Person
|
||||||
from ietf.utils import log
|
from ietf.utils import log
|
||||||
from ietf.utils.mail import send_mail_text, send_mail_preformatted
|
from ietf.utils.mail import send_mail_text, send_mail_preformatted
|
||||||
|
from ietf.utils.decorators import require_api_key
|
||||||
|
|
||||||
BALLOT_CHOICES = (("yes", "Yes"),
|
BALLOT_CHOICES = (("yes", "Yes"),
|
||||||
("noobj", "No Objection"),
|
("noobj", "No Objection"),
|
||||||
|
@ -106,6 +110,74 @@ class EditPositionForm(forms.Form):
|
||||||
raise forms.ValidationError("You must enter a non-empty discuss")
|
raise forms.ValidationError("You must enter a non-empty discuss")
|
||||||
return entered_discuss
|
return entered_discuss
|
||||||
|
|
||||||
|
def save_position(form, doc, ballot, ad, login=None):
|
||||||
|
# save the vote
|
||||||
|
if login is None:
|
||||||
|
login = ad
|
||||||
|
clean = form.cleaned_data
|
||||||
|
|
||||||
|
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
|
||||||
|
pos = BallotPositionDocEvent(doc=doc, rev=doc.rev, by=login)
|
||||||
|
pos.type = "changed_ballot_position"
|
||||||
|
pos.ballot = ballot
|
||||||
|
pos.ad = ad
|
||||||
|
pos.pos = clean["position"]
|
||||||
|
pos.comment = clean["comment"].rstrip()
|
||||||
|
pos.comment_time = old_pos.comment_time if old_pos else None
|
||||||
|
pos.discuss = clean["discuss"].rstrip()
|
||||||
|
if not pos.pos.blocking:
|
||||||
|
pos.discuss = ""
|
||||||
|
pos.discuss_time = old_pos.discuss_time if old_pos else None
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
added_events = []
|
||||||
|
# possibly add discuss/comment comments to history trail
|
||||||
|
# so it's easy to see what's happened
|
||||||
|
old_comment = old_pos.comment if old_pos else ""
|
||||||
|
if pos.comment != old_comment:
|
||||||
|
pos.comment_time = pos.time
|
||||||
|
changes.append("comment")
|
||||||
|
|
||||||
|
if pos.comment:
|
||||||
|
e = DocEvent(doc=doc, rev=doc.rev)
|
||||||
|
e.by = ad # otherwise we can't see who's saying it
|
||||||
|
e.type = "added_comment"
|
||||||
|
e.desc = "[Ballot comment]\n" + pos.comment
|
||||||
|
added_events.append(e)
|
||||||
|
|
||||||
|
old_discuss = old_pos.discuss if old_pos else ""
|
||||||
|
if pos.discuss != old_discuss:
|
||||||
|
pos.discuss_time = pos.time
|
||||||
|
changes.append("discuss")
|
||||||
|
|
||||||
|
if pos.pos.blocking:
|
||||||
|
e = DocEvent(doc=doc, rev=doc.rev, by=login)
|
||||||
|
e.by = ad # otherwise we can't see who's saying it
|
||||||
|
e.type = "added_comment"
|
||||||
|
e.desc = "[Ballot %s]\n" % pos.pos.name.lower()
|
||||||
|
e.desc += pos.discuss
|
||||||
|
added_events.append(e)
|
||||||
|
|
||||||
|
# figure out a description
|
||||||
|
if not old_pos and pos.pos.slug != "norecord":
|
||||||
|
pos.desc = u"[Ballot Position Update] New position, %s, has been recorded for %s" % (pos.pos.name, pos.ad.plain_name())
|
||||||
|
elif old_pos and pos.pos != old_pos.pos:
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not pos.desc and changes:
|
||||||
|
pos.desc = u"Ballot %s text updated for %s" % (u" and ".join(changes), ad.plain_name())
|
||||||
|
|
||||||
|
# only add new event if we actually got a change
|
||||||
|
if pos.desc:
|
||||||
|
if login != ad:
|
||||||
|
pos.desc += u" by %s" % login.plain_name()
|
||||||
|
|
||||||
|
pos.save()
|
||||||
|
|
||||||
|
for e in added_events:
|
||||||
|
e.save() # save them after the position is saved to get later id for sorting order
|
||||||
|
|
||||||
|
|
||||||
@role_required('Area Director','Secretariat')
|
@role_required('Area Director','Secretariat')
|
||||||
def edit_position(request, name, ballot_id):
|
def edit_position(request, name, ballot_id):
|
||||||
"""Vote and edit discuss and comment on document as Area Director."""
|
"""Vote and edit discuss and comment on document as Area Director."""
|
||||||
|
@ -126,8 +198,6 @@ def edit_position(request, name, ballot_id):
|
||||||
raise Http404
|
raise Http404
|
||||||
ad = get_object_or_404(Person, pk=ad_id)
|
ad = get_object_or_404(Person, pk=ad_id)
|
||||||
|
|
||||||
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if not has_role(request.user, "Secretariat") and not ad.role_set.filter(name="ad", group__type="area", group__state="active"):
|
if not has_role(request.user, "Secretariat") and not ad.role_set.filter(name="ad", group__type="area", group__state="active"):
|
||||||
# prevent pre-ADs from voting
|
# prevent pre-ADs from voting
|
||||||
|
@ -135,68 +205,7 @@ def edit_position(request, name, ballot_id):
|
||||||
|
|
||||||
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
|
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# save the vote
|
save_position(form, doc, ballot, ad, login)
|
||||||
clean = form.cleaned_data
|
|
||||||
|
|
||||||
pos = BallotPositionDocEvent(doc=doc, rev=doc.rev, by=login)
|
|
||||||
pos.type = "changed_ballot_position"
|
|
||||||
pos.ballot = ballot
|
|
||||||
pos.ad = ad
|
|
||||||
pos.pos = clean["position"]
|
|
||||||
pos.comment = clean["comment"].rstrip()
|
|
||||||
pos.comment_time = old_pos.comment_time if old_pos else None
|
|
||||||
pos.discuss = clean["discuss"].rstrip()
|
|
||||||
if not pos.pos.blocking:
|
|
||||||
pos.discuss = ""
|
|
||||||
pos.discuss_time = old_pos.discuss_time if old_pos else None
|
|
||||||
|
|
||||||
changes = []
|
|
||||||
added_events = []
|
|
||||||
# possibly add discuss/comment comments to history trail
|
|
||||||
# so it's easy to see what's happened
|
|
||||||
old_comment = old_pos.comment if old_pos else ""
|
|
||||||
if pos.comment != old_comment:
|
|
||||||
pos.comment_time = pos.time
|
|
||||||
changes.append("comment")
|
|
||||||
|
|
||||||
if pos.comment:
|
|
||||||
e = DocEvent(doc=doc, rev=doc.rev)
|
|
||||||
e.by = ad # otherwise we can't see who's saying it
|
|
||||||
e.type = "added_comment"
|
|
||||||
e.desc = "[Ballot comment]\n" + pos.comment
|
|
||||||
added_events.append(e)
|
|
||||||
|
|
||||||
old_discuss = old_pos.discuss if old_pos else ""
|
|
||||||
if pos.discuss != old_discuss:
|
|
||||||
pos.discuss_time = pos.time
|
|
||||||
changes.append("discuss")
|
|
||||||
|
|
||||||
if pos.pos.blocking:
|
|
||||||
e = DocEvent(doc=doc, rev=doc.rev, by=login)
|
|
||||||
e.by = ad # otherwise we can't see who's saying it
|
|
||||||
e.type = "added_comment"
|
|
||||||
e.desc = "[Ballot %s]\n" % pos.pos.name.lower()
|
|
||||||
e.desc += pos.discuss
|
|
||||||
added_events.append(e)
|
|
||||||
|
|
||||||
# figure out a description
|
|
||||||
if not old_pos and pos.pos.slug != "norecord":
|
|
||||||
pos.desc = u"[Ballot Position Update] New position, %s, has been recorded for %s" % (pos.pos.name, pos.ad.plain_name())
|
|
||||||
elif old_pos and pos.pos != old_pos.pos:
|
|
||||||
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)
|
|
||||||
|
|
||||||
if not pos.desc and changes:
|
|
||||||
pos.desc = u"Ballot %s text updated for %s" % (u" and ".join(changes), ad.plain_name())
|
|
||||||
|
|
||||||
# only add new event if we actually got a change
|
|
||||||
if pos.desc:
|
|
||||||
if login != ad:
|
|
||||||
pos.desc += u" by %s" % login.plain_name()
|
|
||||||
|
|
||||||
pos.save()
|
|
||||||
|
|
||||||
for e in added_events:
|
|
||||||
e.save() # save them after the position is saved to get later id for sorting order
|
|
||||||
|
|
||||||
if request.POST.get("send_mail"):
|
if request.POST.get("send_mail"):
|
||||||
qstr=""
|
qstr=""
|
||||||
|
@ -211,6 +220,7 @@ def edit_position(request, name, ballot_id):
|
||||||
return HttpResponseRedirect(return_to_url)
|
return HttpResponseRedirect(return_to_url)
|
||||||
else:
|
else:
|
||||||
initial = {}
|
initial = {}
|
||||||
|
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot)
|
||||||
if old_pos:
|
if old_pos:
|
||||||
initial['position'] = old_pos.pos.slug
|
initial['position'] = old_pos.pos.slug
|
||||||
initial['discuss'] = old_pos.discuss
|
initial['discuss'] = old_pos.discuss
|
||||||
|
@ -234,6 +244,45 @@ def edit_position(request, name, ballot_id):
|
||||||
blocking_positions=json.dumps(blocking_positions),
|
blocking_positions=json.dumps(blocking_positions),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
@require_api_key
|
||||||
|
@role_required('Area Director', 'Secretariat')
|
||||||
|
@csrf_exempt
|
||||||
|
def api_set_position(request):
|
||||||
|
def err(code, text):
|
||||||
|
return HttpResponse(text, status=code, content_type='text/plain')
|
||||||
|
if request.method == 'POST':
|
||||||
|
ad = request.user.person
|
||||||
|
name = request.POST.get('doc')
|
||||||
|
if not name:
|
||||||
|
return err(400, "Missing document name")
|
||||||
|
try:
|
||||||
|
doc = Document.objects.get(docalias__name=name)
|
||||||
|
except Document.DoesNotExist:
|
||||||
|
return err(404, "Document not found")
|
||||||
|
position_names = BallotPositionName.objects.values_list('slug', flat=True)
|
||||||
|
position = request.POST.get('position')
|
||||||
|
if not position:
|
||||||
|
return err(400, "Missing parameter: position, one of: %s " % ','.join(position_names))
|
||||||
|
if not position in position_names:
|
||||||
|
return err(400, "Bad position name, must be one of: %s " % ','.join(position_names))
|
||||||
|
ballot = doc.active_ballot()
|
||||||
|
if not ballot:
|
||||||
|
return err(404, "No open ballot found")
|
||||||
|
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
|
||||||
|
if form.is_valid():
|
||||||
|
save_position(form, doc, ballot, ad)
|
||||||
|
else:
|
||||||
|
debug.type('form.errors')
|
||||||
|
debug.show('form.errors')
|
||||||
|
errors = form.errors
|
||||||
|
summary = ','.join([ "%s: %s" % (f, striptags(errors[f])) for f in errors ])
|
||||||
|
return err(400, "Form not valid: %s" % summary)
|
||||||
|
else:
|
||||||
|
return err(405, "Method not allowed")
|
||||||
|
|
||||||
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
||||||
|
|
||||||
|
|
||||||
@role_required('Area Director','Secretariat')
|
@role_required('Area Director','Secretariat')
|
||||||
def send_ballot_comment(request, name, ballot_id):
|
def send_ballot_comment(request, name, ballot_id):
|
||||||
"""Email document ballot position discuss/comment for Area Director."""
|
"""Email document ballot position discuss/comment for Area Director."""
|
||||||
|
|
0
ietf/ietfauth/management/__init__.py
Normal file
0
ietf/ietfauth/management/__init__.py
Normal file
0
ietf/ietfauth/management/commands/__init__.py
Normal file
0
ietf/ietfauth/management/commands/__init__.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright The IETF Trust 2017, All Rights Reserved
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
import debug # pyflakes:ignore
|
||||||
|
|
||||||
|
from ietf.person.models import PersonalApiKey, PersonApiKeyEvent
|
||||||
|
from ietf.utils.mail import send_mail
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Send out emails to all persons who have personal API keys about usage.
|
||||||
|
|
||||||
|
Usage is show over the given period, where the default period is 7 days.
|
||||||
|
"""
|
||||||
|
|
||||||
|
help = dedent(__doc__).strip()
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('-d', '--days', dest='days', type=int, default=7,
|
||||||
|
help='The period over which to show usage.')
|
||||||
|
|
||||||
|
def handle(self, *filenames, **options):
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.verbosity = int(options.get('verbosity'))
|
||||||
|
days = options.get('days')
|
||||||
|
|
||||||
|
keys = PersonalApiKey.objects.filter(valid=True)
|
||||||
|
for key in keys:
|
||||||
|
earliest = datetime.datetime.now() - datetime.timedelta(days=days)
|
||||||
|
events = PersonApiKeyEvent.objects.filter(key=key, time__gt=earliest)
|
||||||
|
count = events.count()
|
||||||
|
events = events[:32]
|
||||||
|
if count:
|
||||||
|
key_name = key.hash()[:8]
|
||||||
|
subject = "API key usage for key '%s' for the last %s days" %(key_name, days)
|
||||||
|
to = key.person.email_address()
|
||||||
|
frm = settings.DEFAULT_FROM_EMAIL
|
||||||
|
send_mail(None, to, frm, subject, 'utils/apikey_usage_report.txt', {'person':key.person,
|
||||||
|
'days':days, 'key':key, 'key_name':key_name, 'count':count, 'events':events, } )
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
# Copyright The IETF Trust 2017, All Rights Reserved
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals, print_function
|
||||||
|
|
||||||
import os, shutil, time, datetime
|
import os, shutil, time, datetime
|
||||||
from urlparse import urlsplit
|
from urlparse import urlsplit
|
||||||
|
@ -16,10 +17,12 @@ import debug # pyflakes:ignore
|
||||||
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
|
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
|
||||||
from ietf.utils.test_data import make_test_data, make_review_data
|
from ietf.utils.test_data import make_test_data, make_review_data
|
||||||
from ietf.utils.mail import outbox, empty_outbox
|
from ietf.utils.mail import outbox, empty_outbox
|
||||||
from ietf.person.models import Person, Email
|
|
||||||
from ietf.group.models import Group, Role, RoleName
|
from ietf.group.models import Group, Role, RoleName
|
||||||
|
from ietf.group.factories import GroupFactory
|
||||||
from ietf.ietfauth.htpasswd import update_htpasswd_file
|
from ietf.ietfauth.htpasswd import update_htpasswd_file
|
||||||
from ietf.mailinglists.models import Subscribed
|
from ietf.mailinglists.models import Subscribed
|
||||||
|
from ietf.person.models import Person, Email, PersonalApiKey, PERSON_API_KEY_ENDPOINTS
|
||||||
|
from ietf.person.factories import PersonFactory
|
||||||
from ietf.review.models import ReviewWish, UnavailablePeriod
|
from ietf.review.models import ReviewWish, UnavailablePeriod
|
||||||
from ietf.utils.decorators import skip_coverage
|
from ietf.utils.decorators import skip_coverage
|
||||||
|
|
||||||
|
@ -99,7 +102,7 @@ class IetfAuthTests(TestCase):
|
||||||
if l.startswith(username + ":"):
|
if l.startswith(username + ":"):
|
||||||
return True
|
return True
|
||||||
with open(settings.HTPASSWD_FILE) as f:
|
with open(settings.HTPASSWD_FILE) as f:
|
||||||
print f.read()
|
print(f.read())
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -495,3 +498,145 @@ class IetfAuthTests(TestCase):
|
||||||
user = User.objects.get(username="othername@example.org")
|
user = User.objects.get(username="othername@example.org")
|
||||||
self.assertEqual(prev, user)
|
self.assertEqual(prev, user)
|
||||||
self.assertTrue(user.check_password(u'password'))
|
self.assertTrue(user.check_password(u'password'))
|
||||||
|
|
||||||
|
def test_apikey_management(self):
|
||||||
|
person = PersonFactory()
|
||||||
|
|
||||||
|
url = urlreverse('ietf.ietfauth.views.apikey_index')
|
||||||
|
|
||||||
|
# Check that the url is protected, then log in
|
||||||
|
login_testing_unauthorized(self, person.user.username, url)
|
||||||
|
|
||||||
|
# Check api key list content
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertContains(r, 'Personal API keys')
|
||||||
|
self.assertContains(r, 'Get a new personal API key')
|
||||||
|
|
||||||
|
# Check the add key form content
|
||||||
|
url = urlreverse('ietf.ietfauth.views.apikey_create')
|
||||||
|
r = self.client.get(url)
|
||||||
|
self.assertContains(r, 'Create a new personal API key')
|
||||||
|
self.assertContains(r, 'Endpoint')
|
||||||
|
|
||||||
|
# Add 2 keys
|
||||||
|
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
|
||||||
|
r = self.client.post(url, {'endpoint': endpoint})
|
||||||
|
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
|
||||||
|
|
||||||
|
# Check api key list content
|
||||||
|
url = urlreverse('ietf.ietfauth.views.apikey_index')
|
||||||
|
r = self.client.get(url)
|
||||||
|
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
|
||||||
|
self.assertContains(r, endpoint)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertEqual(len(q('td code')), len(PERSON_API_KEY_ENDPOINTS)) # hash
|
||||||
|
self.assertEqual(len(q('td a:contains("Disable")')), len(PERSON_API_KEY_ENDPOINTS))
|
||||||
|
|
||||||
|
# Get one of the keys
|
||||||
|
key = person.apikeys.first()
|
||||||
|
|
||||||
|
# Check the disable key form content
|
||||||
|
url = urlreverse('ietf.ietfauth.views.apikey_disable')
|
||||||
|
r = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertContains(r, 'Disable a personal API key')
|
||||||
|
self.assertContains(r, 'Key')
|
||||||
|
|
||||||
|
# Delete a key
|
||||||
|
r = self.client.post(url, {'hash': key.hash()})
|
||||||
|
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
|
||||||
|
|
||||||
|
# Check the api key list content again
|
||||||
|
url = urlreverse('ietf.ietfauth.views.apikey_index')
|
||||||
|
r = self.client.get(url)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertEqual(len(q('td code')), len(PERSON_API_KEY_ENDPOINTS)) # key hash
|
||||||
|
self.assertEqual(len(q('td a:contains("Disable")')), len(PERSON_API_KEY_ENDPOINTS)-1)
|
||||||
|
|
||||||
|
def test_apikey_errors(self):
|
||||||
|
BAD_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
|
||||||
|
person = PersonFactory()
|
||||||
|
area = GroupFactory(type_id='area')
|
||||||
|
area.role_set.create(name_id='ad', person=person, email=person.email())
|
||||||
|
|
||||||
|
url = urlreverse('ietf.ietfauth.views.apikey_create')
|
||||||
|
# Check that the url is protected, then log in
|
||||||
|
login_testing_unauthorized(self, person.user.username, url)
|
||||||
|
|
||||||
|
# Add keys
|
||||||
|
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
|
||||||
|
r = self.client.post(url, {'endpoint': endpoint})
|
||||||
|
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
|
||||||
|
|
||||||
|
for key in person.apikeys.all()[:3]:
|
||||||
|
url = key.endpoint
|
||||||
|
|
||||||
|
# bad method
|
||||||
|
r = self.client.put(url, {'apikey':key.hash()})
|
||||||
|
self.assertEqual(r.status_code, 405)
|
||||||
|
|
||||||
|
# missing apikey
|
||||||
|
r = self.client.post(url, {'dummy':'dummy',})
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
self.assertIn('Missing apikey parameter', unicontent(r))
|
||||||
|
|
||||||
|
# invalid apikey
|
||||||
|
r = self.client.post(url, {'apikey':BAD_KEY, 'dummy':'dummy',})
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
self.assertIn('Invalid apikey', unicontent(r))
|
||||||
|
|
||||||
|
# too long since regular login
|
||||||
|
person.user.last_login = datetime.datetime.now() - datetime.timedelta(days=settings.UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS+1)
|
||||||
|
person.user.save()
|
||||||
|
r = self.client.post(url, {'apikey':key.hash(), 'dummy':'dummy',})
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
self.assertIn('Too long since last regular login', unicontent(r))
|
||||||
|
person.user.last_login = datetime.datetime.now()
|
||||||
|
person.user.save()
|
||||||
|
|
||||||
|
# endpoint mismatch
|
||||||
|
key2 = PersonalApiKey.objects.create(person=person, endpoint='/')
|
||||||
|
r = self.client.post(url, {'apikey':key2.hash(), 'dummy':'dummy',})
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
self.assertIn('Apikey endpoint mismatch', unicontent(r))
|
||||||
|
key2.delete()
|
||||||
|
|
||||||
|
def test_send_apikey_report(self):
|
||||||
|
from ietf.ietfauth.management.commands.send_apikey_usage_emails import Command
|
||||||
|
from ietf.utils.mail import outbox, empty_outbox
|
||||||
|
|
||||||
|
person = PersonFactory()
|
||||||
|
|
||||||
|
url = urlreverse('ietf.ietfauth.views.apikey_create')
|
||||||
|
# Check that the url is protected, then log in
|
||||||
|
login_testing_unauthorized(self, person.user.username, url)
|
||||||
|
|
||||||
|
# Add keys
|
||||||
|
for endpoint, display in PERSON_API_KEY_ENDPOINTS:
|
||||||
|
r = self.client.post(url, {'endpoint': endpoint})
|
||||||
|
self.assertRedirects(r, urlreverse('ietf.ietfauth.views.apikey_index'))
|
||||||
|
|
||||||
|
# Use the endpoints (the form content will not be acceptable, but the
|
||||||
|
# apikey usage will be registered)
|
||||||
|
count = 2
|
||||||
|
# avoid usage across dates
|
||||||
|
if datetime.datetime.now().time() > datetime.time(hour=23, minute=59, second=58):
|
||||||
|
time.sleep(2)
|
||||||
|
for i in range(count):
|
||||||
|
for key in person.apikeys.all():
|
||||||
|
url = key.endpoint
|
||||||
|
self.client.post(url, {'apikey':key.hash(), 'dummy': 'dummy', })
|
||||||
|
date = str(datetime.date.today())
|
||||||
|
|
||||||
|
empty_outbox()
|
||||||
|
cmd = Command()
|
||||||
|
cmd.handle(verbosity=0, days=7)
|
||||||
|
|
||||||
|
self.assertEqual(len(outbox), len(PERSON_API_KEY_ENDPOINTS))
|
||||||
|
for mail in outbox:
|
||||||
|
body = mail.get_payload(decode=True).decode('utf-8')
|
||||||
|
self.assertIn("API key usage", mail['subject'])
|
||||||
|
self.assertIn(" %s times" % count, body)
|
||||||
|
self.assertIn(date, body)
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,9 @@ from ietf.utils.urls import url
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^$', views.index),
|
url(r'^$', views.index),
|
||||||
|
url(r'^apikey/?$', views.apikey_index),
|
||||||
|
url(r'^apikey/add/?$', views.apikey_create),
|
||||||
|
url(r'^apikey/del/?$', views.apikey_disable),
|
||||||
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
|
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
|
||||||
url(r'^create/$', views.create_account),
|
url(r'^create/$', views.create_account),
|
||||||
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),
|
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),
|
||||||
|
|
|
@ -48,6 +48,7 @@ from django.contrib.auth.hashers import identify_hasher
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.views import login as django_login
|
from django.contrib.auth.views import login as django_login
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.validators import ValidationError
|
||||||
from django.urls import reverse as urlreverse
|
from django.urls import reverse as urlreverse
|
||||||
from django.http import Http404, HttpResponseRedirect #, HttpResponse,
|
from django.http import Http404, HttpResponseRedirect #, HttpResponse,
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
@ -61,11 +62,12 @@ from ietf.ietfauth.forms import ( RegistrationForm, PasswordForm, ResetPasswordF
|
||||||
from ietf.ietfauth.htpasswd import update_htpasswd_file
|
from ietf.ietfauth.htpasswd import update_htpasswd_file
|
||||||
from ietf.ietfauth.utils import role_required
|
from ietf.ietfauth.utils import role_required
|
||||||
from ietf.mailinglists.models import Subscribed, Whitelisted
|
from ietf.mailinglists.models import Subscribed, Whitelisted
|
||||||
from ietf.person.models import Person, Email, Alias
|
from ietf.person.models import Person, Email, Alias, PersonalApiKey
|
||||||
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish
|
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish
|
||||||
from ietf.review.utils import unavailable_periods_to_list, get_default_filter_re
|
from ietf.review.utils import unavailable_periods_to_list, get_default_filter_re
|
||||||
from ietf.utils.mail import send_mail
|
|
||||||
from ietf.doc.fields import SearchableDocumentField
|
from ietf.doc.fields import SearchableDocumentField
|
||||||
|
from ietf.utils.decorators import person_required
|
||||||
|
from ietf.utils.mail import send_mail
|
||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
return render(request, 'registration/index.html')
|
return render(request, 'registration/index.html')
|
||||||
|
@ -190,14 +192,10 @@ def confirm_account(request, auth):
|
||||||
})
|
})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@person_required
|
||||||
def profile(request):
|
def profile(request):
|
||||||
roles = []
|
roles = []
|
||||||
person = None
|
person = request.user.person
|
||||||
|
|
||||||
try:
|
|
||||||
person = request.user.person
|
|
||||||
except Person.DoesNotExist:
|
|
||||||
return render(request, 'registration/missing_person.html')
|
|
||||||
|
|
||||||
roles = Role.objects.filter(person=person, group__state='active').order_by('name__name', 'group__name')
|
roles = Role.objects.filter(person=person, group__state='active').order_by('name__name', 'group__name')
|
||||||
emails = Email.objects.filter(person=person).order_by('-active','-time')
|
emails = Email.objects.filter(person=person).order_by('-active','-time')
|
||||||
|
@ -533,13 +531,9 @@ def change_password(request):
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@person_required
|
||||||
def change_username(request):
|
def change_username(request):
|
||||||
person = None
|
person = request.user.person
|
||||||
|
|
||||||
try:
|
|
||||||
person = request.user.person
|
|
||||||
except Person.DoesNotExist:
|
|
||||||
return render(request, 'registration/missing_person.html')
|
|
||||||
|
|
||||||
emails = [ e.address for e in Email.objects.filter(person=person, active=True) ]
|
emails = [ e.address for e in Email.objects.filter(person=person, active=True) ]
|
||||||
emailz = [ e.address for e in person.email_set.filter(active=True) ]
|
emailz = [ e.address for e in person.email_set.filter(active=True) ]
|
||||||
|
@ -599,3 +593,61 @@ def login(request, extra_context=None):
|
||||||
}
|
}
|
||||||
|
|
||||||
return django_login(request, extra_context=extra_context)
|
return django_login(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@person_required
|
||||||
|
def apikey_index(request):
|
||||||
|
person = request.user.person
|
||||||
|
return render(request, 'ietfauth/apikeys.html', {'person': person})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@person_required
|
||||||
|
def apikey_create(request):
|
||||||
|
class ApiKeyForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = PersonalApiKey
|
||||||
|
fields = ['endpoint']
|
||||||
|
#
|
||||||
|
person = request.user.person
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ApiKeyForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
api_key = form.save(commit=False)
|
||||||
|
api_key.person = person
|
||||||
|
api_key.save()
|
||||||
|
return redirect('ietf.ietfauth.views.apikey_index')
|
||||||
|
else:
|
||||||
|
form = ApiKeyForm()
|
||||||
|
return render(request, 'form.html', {'form':form, 'title':"Create a new personal API key", 'description':'', 'button':'Create key'})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@person_required
|
||||||
|
def apikey_disable(request):
|
||||||
|
person = request.user.person
|
||||||
|
choices = [ (k.hash(), str(k)) for k in person.apikeys.all() ]
|
||||||
|
#
|
||||||
|
class KeyDeleteForm(forms.Form):
|
||||||
|
hash = forms.ChoiceField(label='Key', choices=choices)
|
||||||
|
def clean_key(self):
|
||||||
|
hash = self.cleaned_data['hash']
|
||||||
|
key = PersonalApiKey.validate_key(hash)
|
||||||
|
if key and key.person == request.user.person:
|
||||||
|
return hash
|
||||||
|
else:
|
||||||
|
raise ValidationError("Bad key value")
|
||||||
|
#
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = KeyDeleteForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
hash = form.data['hash']
|
||||||
|
key = PersonalApiKey.validate_key(hash)
|
||||||
|
key.valid = False
|
||||||
|
key.save()
|
||||||
|
messages.success(request, "Disabled key %s" % hash)
|
||||||
|
return redirect('ietf.ietfauth.views.apikey_index')
|
||||||
|
else:
|
||||||
|
messages.error(request, "Key validation failed; key not disabled")
|
||||||
|
else:
|
||||||
|
form = KeyDeleteForm(request.GET)
|
||||||
|
return render(request, 'form.html', {'form':form, 'title':"Disable a personal API key", 'description':'', 'button':'Disable key'})
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
|
|
||||||
from ietf.person.models import Email, Alias, Person, PersonHistory
|
from ietf.person.models import Email, Alias, Person, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent
|
||||||
from ietf.person.name import name_parts
|
from ietf.person.name import name_parts
|
||||||
|
|
||||||
class EmailAdmin(admin.ModelAdmin):
|
class EmailAdmin(admin.ModelAdmin):
|
||||||
|
@ -43,4 +43,21 @@ class PersonHistoryAdmin(admin.ModelAdmin):
|
||||||
search_fields = ['name', 'ascii']
|
search_fields = ['name', 'ascii']
|
||||||
admin.site.register(PersonHistory, PersonHistoryAdmin)
|
admin.site.register(PersonHistory, PersonHistoryAdmin)
|
||||||
|
|
||||||
|
class PersonalApiKeyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['id', 'person', 'created', 'endpoint', 'valid', 'count', 'latest', ]
|
||||||
|
list_filter = ['endpoint', 'created', ]
|
||||||
|
raw_id_fields = ['person', ]
|
||||||
|
search_fields = ['person__name', ]
|
||||||
|
admin.site.register(PersonalApiKey, PersonalApiKeyAdmin)
|
||||||
|
|
||||||
|
class PersonEventAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "person", "time", "type", ]
|
||||||
|
search_fields = ["person__name", ]
|
||||||
|
raw_id_fields = ['person', ]
|
||||||
|
admin.site.register(PersonEvent, PersonEventAdmin)
|
||||||
|
|
||||||
|
class PersonApiKeyEventAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "person", "time", "type", "key"]
|
||||||
|
search_fields = ["person__name", ]
|
||||||
|
raw_id_fields = ['person', ]
|
||||||
|
admin.site.register(PersonApiKeyEvent, PersonApiKeyEventAdmin)
|
||||||
|
|
56
ietf/person/migrations/0021_personalapikey.py
Normal file
56
ietf/person/migrations/0021_personalapikey.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.10.8 on 2017-12-15 08:19
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import ietf.person.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('person', '0020_auto_20170701_0325'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PersonalApiKey',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('endpoint', models.CharField(choices=[(b'/api/submit', b'/api/submit'), (b'/api/iesg/position', b'/api/iesg/position')], max_length=128)),
|
||||||
|
('created', models.DateTimeField(default=datetime.datetime.now)),
|
||||||
|
('valid', models.BooleanField(default=True)),
|
||||||
|
('salt', models.BinaryField(default=ietf.person.models.salt, max_length=12)),
|
||||||
|
('count', models.IntegerField(default=0)),
|
||||||
|
('latest', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='apikeys', to='person.Person')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PersonEvent',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('time', models.DateTimeField(default=datetime.datetime.now, help_text=b'When the event happened')),
|
||||||
|
('type', models.CharField(choices=[(b'apikey_login', b'API key login')], max_length=50)),
|
||||||
|
('desc', models.TextField()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-time', '-id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PersonApiKeyEvent',
|
||||||
|
fields=[
|
||||||
|
('personevent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='person.PersonEvent')),
|
||||||
|
('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='person.PersonalApiKey')),
|
||||||
|
],
|
||||||
|
bases=('person.personevent',),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='personevent',
|
||||||
|
name='person',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='person.Person'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -3,6 +3,9 @@
|
||||||
import datetime
|
import datetime
|
||||||
import email.utils
|
import email.utils
|
||||||
import email.header
|
import email.header
|
||||||
|
import six
|
||||||
|
import uuid
|
||||||
|
|
||||||
from hashids import Hashids
|
from hashids import Hashids
|
||||||
from urlparse import urljoin
|
from urlparse import urljoin
|
||||||
|
|
||||||
|
@ -274,3 +277,71 @@ class Email(models.Model):
|
||||||
return
|
return
|
||||||
return self.address
|
return self.address
|
||||||
|
|
||||||
|
|
||||||
|
# "{key.id}{salt}{hash}
|
||||||
|
KEY_STRUCT = "i12s32s"
|
||||||
|
|
||||||
|
def salt():
|
||||||
|
return uuid.uuid4().bytes[:12]
|
||||||
|
|
||||||
|
# Manual maintenance: List all endpoints that use @require_api_key here
|
||||||
|
PERSON_API_KEY_ENDPOINTS = [
|
||||||
|
("/api/iesg/position", "/api/iesg/position"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class PersonalApiKey(models.Model):
|
||||||
|
person = models.ForeignKey(Person, related_name='apikeys')
|
||||||
|
endpoint = models.CharField(max_length=128, null=False, blank=False, choices=PERSON_API_KEY_ENDPOINTS)
|
||||||
|
created = models.DateTimeField(default=datetime.datetime.now, null=False)
|
||||||
|
valid = models.BooleanField(default=True)
|
||||||
|
salt = models.BinaryField(default=salt, max_length=12, null=False, blank=False)
|
||||||
|
count = models.IntegerField(default=0, null=False, blank=False)
|
||||||
|
latest = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_key(cls, s):
|
||||||
|
import struct, hashlib, base64
|
||||||
|
key = base64.urlsafe_b64decode(six.binary_type(s))
|
||||||
|
id, salt, hash = struct.unpack(KEY_STRUCT, key)
|
||||||
|
k = cls.objects.filter(id=id)
|
||||||
|
if not k.exists():
|
||||||
|
return None
|
||||||
|
k = k.first()
|
||||||
|
check = hashlib.sha256()
|
||||||
|
for v in (str(id), str(k.person.id), k.created.isoformat(), k.endpoint, str(k.valid), salt, settings.SECRET_KEY):
|
||||||
|
check.update(v)
|
||||||
|
return k if check.digest() == hash else None
|
||||||
|
|
||||||
|
def hash(self):
|
||||||
|
import struct, hashlib, base64
|
||||||
|
if not hasattr(self, '_cached_hash'):
|
||||||
|
hash = hashlib.sha256()
|
||||||
|
# Hash over: ( id, person, created, endpoint, valid, salt, secret )
|
||||||
|
for v in (str(self.id), str(self.person.id), self.created.isoformat(), self.endpoint, str(self.valid), self.salt, settings.SECRET_KEY):
|
||||||
|
hash.update(v)
|
||||||
|
key = struct.pack(KEY_STRUCT, self.id, six.binary_type(self.salt), hash.digest())
|
||||||
|
self._cached_hash = base64.urlsafe_b64encode(key)
|
||||||
|
return self._cached_hash
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return "%s (%s): %s ..." % (self.endpoint, self.created.strftime("%Y-%m-%d %H:%M"), self.hash()[:16])
|
||||||
|
|
||||||
|
PERSON_EVENT_CHOICES = [
|
||||||
|
("apikey_login", "API key login"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class PersonEvent(models.Model):
|
||||||
|
person = models.ForeignKey(Person)
|
||||||
|
time = models.DateTimeField(default=datetime.datetime.now, help_text="When the event happened")
|
||||||
|
type = models.CharField(max_length=50, choices=PERSON_EVENT_CHOICES)
|
||||||
|
desc = models.TextField()
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return u"%s %s at %s" % (self.person.plain_name(), self.get_type_display().lower(), self.time)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-time', '-id']
|
||||||
|
|
||||||
|
class PersonApiKeyEvent(PersonEvent):
|
||||||
|
key = models.ForeignKey(PersonalApiKey)
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ from tastypie.cache import SimpleCache
|
||||||
|
|
||||||
from ietf import api
|
from ietf import api
|
||||||
|
|
||||||
from ietf.person.models import (Person, Email, Alias, PersonHistory)
|
from ietf.person.models import (Person, Email, Alias, PersonHistory, PersonalApiKey, PersonEvent, PersonApiKeyEvent)
|
||||||
|
|
||||||
|
|
||||||
from ietf.utils.resources import UserResource
|
from ietf.utils.resources import UserResource
|
||||||
|
@ -82,3 +82,61 @@ class PersonHistoryResource(ModelResource):
|
||||||
"user": ALL_WITH_RELATIONS,
|
"user": ALL_WITH_RELATIONS,
|
||||||
}
|
}
|
||||||
api.person.register(PersonHistoryResource())
|
api.person.register(PersonHistoryResource())
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalApiKeyResource(ModelResource):
|
||||||
|
person = ToOneField(PersonResource, 'person')
|
||||||
|
class Meta:
|
||||||
|
queryset = PersonalApiKey.objects.all()
|
||||||
|
serializer = api.Serializer()
|
||||||
|
cache = SimpleCache()
|
||||||
|
#resource_name = 'personalapikey'
|
||||||
|
filtering = {
|
||||||
|
"id": ALL,
|
||||||
|
"endpoint": ALL,
|
||||||
|
"created": ALL,
|
||||||
|
"valid": ALL,
|
||||||
|
"salt": ALL,
|
||||||
|
"count": ALL,
|
||||||
|
"latest": ALL,
|
||||||
|
"person": ALL_WITH_RELATIONS,
|
||||||
|
}
|
||||||
|
api.person.register(PersonalApiKeyResource())
|
||||||
|
|
||||||
|
|
||||||
|
class PersonEventResource(ModelResource):
|
||||||
|
person = ToOneField(PersonResource, 'person')
|
||||||
|
class Meta:
|
||||||
|
queryset = PersonEvent.objects.all()
|
||||||
|
serializer = api.Serializer()
|
||||||
|
cache = SimpleCache()
|
||||||
|
#resource_name = 'personevent'
|
||||||
|
filtering = {
|
||||||
|
"id": ALL,
|
||||||
|
"time": ALL,
|
||||||
|
"type": ALL,
|
||||||
|
"desc": ALL,
|
||||||
|
"person": ALL_WITH_RELATIONS,
|
||||||
|
}
|
||||||
|
api.person.register(PersonEventResource())
|
||||||
|
|
||||||
|
|
||||||
|
class PersonApiKeyEventResource(ModelResource):
|
||||||
|
person = ToOneField(PersonResource, 'person')
|
||||||
|
personevent_ptr = ToOneField(PersonEventResource, 'personevent_ptr')
|
||||||
|
key = ToOneField(PersonalApiKeyResource, 'key')
|
||||||
|
class Meta:
|
||||||
|
queryset = PersonApiKeyEvent.objects.all()
|
||||||
|
serializer = api.Serializer()
|
||||||
|
cache = SimpleCache()
|
||||||
|
#resource_name = 'personapikeyevent'
|
||||||
|
filtering = {
|
||||||
|
"id": ALL,
|
||||||
|
"time": ALL,
|
||||||
|
"type": ALL,
|
||||||
|
"desc": ALL,
|
||||||
|
"person": ALL_WITH_RELATIONS,
|
||||||
|
"personevent_ptr": ALL_WITH_RELATIONS,
|
||||||
|
"key": ALL_WITH_RELATIONS,
|
||||||
|
}
|
||||||
|
api.person.register(PersonApiKeyEventResource())
|
||||||
|
|
|
@ -932,6 +932,7 @@ SILENCED_SYSTEM_CHECKS = [
|
||||||
STATS_NAMES_LIMIT = 25
|
STATS_NAMES_LIMIT = 25
|
||||||
|
|
||||||
UTILS_TEST_RANDOM_STATE_FILE = '.factoryboy_random_state'
|
UTILS_TEST_RANDOM_STATE_FILE = '.factoryboy_random_state'
|
||||||
|
UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS = 30
|
||||||
|
|
||||||
API_PUBLIC_KEY_PEM = """
|
API_PUBLIC_KEY_PEM = """
|
||||||
-----BEGIN PUBLIC KEY-----
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
|
|
@ -8,6 +8,11 @@
|
||||||
<div class="col-md-1"></div>
|
<div class="col-md-1"></div>
|
||||||
<div class="col-md-11 bio-text">
|
<div class="col-md-11 bio-text">
|
||||||
<h3>Framework</h3>
|
<h3>Framework</h3>
|
||||||
|
<p>
|
||||||
|
This section describes the autogenrated read-only API towards the database tables. See also
|
||||||
|
the <a href="{% url 'ietf.submit.views.api_submit' %}">draft submission API description</a> and the
|
||||||
|
<a href="#iesg-position-api">IESG ballot position API description</a>
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The datatracker API uses <a href="https://django-tastypie.readthedocs.org/">tastypie</a>
|
The datatracker API uses <a href="https://django-tastypie.readthedocs.org/">tastypie</a>
|
||||||
to generate an API which mirrors the Django ORM (Object Relational Mapping)
|
to generate an API which mirrors the Django ORM (Object Relational Mapping)
|
||||||
|
@ -165,8 +170,8 @@
|
||||||
|
|
||||||
The datatracker does not use any form of API keys currently (03 Nov
|
The datatracker does not use any form of API keys currently (03 Nov
|
||||||
2017), but may do so in the future. If so, personal API keys will be
|
2017), but may do so in the future. If so, personal API keys will be
|
||||||
available from your <a href={% url 'ietf.ietfauth.views.profile'
|
available from your <a href="{% url 'ietf.ietfauth.views.apikey_index'%}">
|
||||||
%}>Account Profile</a> page when you are logged in, and document keys
|
Account API Key</a> page when you are logged in, and document keys
|
||||||
will be visible to document authors on the document status page when
|
will be visible to document authors on the document status page when
|
||||||
logged in.
|
logged in.
|
||||||
|
|
||||||
|
@ -186,8 +191,38 @@
|
||||||
|
|
||||||
<pre>{{key.export_to_pem}}</pre>
|
<pre>{{key.export_to_pem}}</pre>
|
||||||
|
|
||||||
</div>
|
<h3 id="iesg-position-api">IESG ballot position API</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A simplified IESG ballot position interface, intended for automation,
|
||||||
|
is available at <code>{% url 'ietf.doc.views_ballot.api_set_position' %}</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The interface requires the use of a personal API key, which can be created at
|
||||||
|
<a href="{% url 'ietf.ietfauth.views.apikey_index' %}">{% url 'ietf.ietfauth.views.apikey_index' %}</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It takes the following parameters:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>apikey</code> (required) which is the personal API key hash</li>
|
||||||
|
<li><code>doc</code> (required) which is the balloted document name</li>
|
||||||
|
<li><code>position</code> (required) which is the position slug, one of: yes, noobj, block, discuss, abstain, recuse, norecord </li>
|
||||||
|
<li><code>discuss</code> (required if position is 'discuss') which is the discuss text</li>
|
||||||
|
<li><code>comment</code> (optional) which is comment text</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
It returns an appropriate http result code, and a brief explanatory text message.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Here is an example:</li>
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
$ curl -S -F "apikey=AwAAABVR3D5GHkVMhspKSxBCVknGMmqikNIhT85kSnghjaV_pYy26WV92mm-jpdi" -F "doc=draft-ietf-lamps-eai-addresses" -F "position=noobj" -F "comment=Comment text" https://datatracker.ietf.org/api/iesg/position
|
||||||
|
Done
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -108,7 +108,7 @@
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{{ content|safe }}{% endblock %}
|
||||||
{% block content_end %}{% endblock %}
|
{% block content_end %}{% endblock %}
|
||||||
{% if request.COOKIES.left_menu != "off" and not hide_menu %}
|
{% if request.COOKIES.left_menu != "off" and not hide_menu %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
<li><a rel="nofollow" href="/accounts/logout/" >Sign out</a></li>
|
<li><a rel="nofollow" href="/accounts/logout/" >Sign out</a></li>
|
||||||
<li><a rel="nofollow" href="/accounts/profile/">Account info</a></li>
|
<li><a rel="nofollow" href="/accounts/profile/">Account info</a></li>
|
||||||
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
|
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
|
||||||
|
<li><a href="{%url "ietf.ietfauth.views.apikey_index" %}" rel="nofollow">API keys</a></li>
|
||||||
<li><a rel="nofollow" href="/accounts/password/">Change password</a></li>
|
<li><a rel="nofollow" href="/accounts/password/">Change password</a></li>
|
||||||
<li><a rel="nofollow" href="/accounts/username/">Change username</a></li>
|
<li><a rel="nofollow" href="/accounts/username/">Change username</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
34
ietf/templates/form.html
Normal file
34
ietf/templates/form.html
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||||
|
{% load origin %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
|
||||||
|
{% block title %}{{ title|striptags }}{% endblock %}
|
||||||
|
|
||||||
|
{% block pagehead %}
|
||||||
|
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% origin %}
|
||||||
|
<h1>{{ title|safe }}</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ description|safe }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" class="show-required">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
|
||||||
|
{% buttons %}
|
||||||
|
<button type="submit" name="{{button|slugify}}" class="btn btn-primary">{{ button }}</button>
|
||||||
|
{% endbuttons %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
|
||||||
|
{% endblock %}
|
51
ietf/templates/ietfauth/apikeys.html
Normal file
51
ietf/templates/ietfauth/apikeys.html
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||||
|
{% load origin %}
|
||||||
|
|
||||||
|
{% load widget_tweaks bootstrap3 %}
|
||||||
|
|
||||||
|
{% load person_filters %}
|
||||||
|
|
||||||
|
{% block title %}API keys for {{ user }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% origin %}
|
||||||
|
<h1>API keys for {{ user.username }}</h1>
|
||||||
|
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-sm-2 control-label">Personal API keys</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<div>
|
||||||
|
<table class="table table-condensed">
|
||||||
|
{% for key in person.apikeys.all %}
|
||||||
|
{% if forloop.first %}
|
||||||
|
<tr ><th>Endpoint</th><th>Created</th><th>Latest use</th><th>Count</th><th>Valid</th></tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key.endpoint }} </td>
|
||||||
|
<td>{{ key.created }} </td>
|
||||||
|
<td>{{ key.latest }} </td>
|
||||||
|
<td>{{ key.count }} </td>
|
||||||
|
<td>{{ key.valid }} </td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="border-top: 0" colspan="4"><code>{{ key.hash }}</code></td>
|
||||||
|
<td style="border-top: 0">
|
||||||
|
{% if key.valid %}
|
||||||
|
<a href="{%url 'ietf.ietfauth.views.apikey_disable' %}?hash={{key.hash}}" class="btn btn-warning btn-xs del-apikey">Disable</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td>You have no personal API keys.</td></tr>
|
||||||
|
<tr><td></td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<a href="{% url 'ietf.ietfauth.views.apikey_create' %}" class="btn btn-default btn-sm add-apikey">Get a new personal API key</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -34,8 +34,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-12">
|
{% if person.role_set.exists %}
|
||||||
{% if person.role_set.exists %}
|
<div class="col-md-12">
|
||||||
<h2 id="roles">Roles</h2>
|
<h2 id="roles">Roles</h2>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
{% for role in person.role_set.all|active_roles %}
|
{% for role in person.role_set.all|active_roles %}
|
||||||
|
@ -54,8 +54,10 @@
|
||||||
{{ person.first_name }} has no active roles as of {{ today }}.
|
{{ person.first_name }} has no active roles as of {{ today }}.
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h2 id="rfcs">RFCs</h2>
|
<h2 id="rfcs">RFCs</h2>
|
||||||
|
|
14
ietf/templates/utils/apikey_usage_report.txt
Normal file
14
ietf/templates/utils/apikey_usage_report.txt
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{% load ietf_filters %}{% filter wordwrap:78 %}
|
||||||
|
Dear {{ person.plain_name }},
|
||||||
|
|
||||||
|
This is a summary of API key usage during the last {{ days }} days for the key '{{ key_name }}',
|
||||||
|
created {{ key.created }} for endpoint {{ key.endpoint }}.
|
||||||
|
|
||||||
|
This API key was used {{ count }} times during this period. Showing {{ events|length }} access times:
|
||||||
|
{% for e in events %}
|
||||||
|
{{ e.time }}{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The IETF Secretariat
|
||||||
|
{% endfilter %}
|
|
@ -1,10 +1,18 @@
|
||||||
# Copyright The IETF Trust 2016, All Rights Reserved
|
# Copyright The IETF Trust 2016, All Rights Reserved
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
from decorator import decorator
|
from decorator import decorator
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import login
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
from test_runner import set_coverage_checking
|
import debug # pyflakes:ignore
|
||||||
|
|
||||||
|
from ietf.utils.test_runner import set_coverage_checking
|
||||||
|
from ietf.person.models import Person, PersonalApiKey, PersonApiKeyEvent
|
||||||
|
|
||||||
@decorator
|
@decorator
|
||||||
def skip_coverage(f, *args, **kwargs):
|
def skip_coverage(f, *args, **kwargs):
|
||||||
|
@ -15,3 +23,54 @@ def skip_coverage(f, *args, **kwargs):
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
@decorator
|
||||||
|
def person_required(f, request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise ValueError("The @person_required decorator should be called after @login_required.")
|
||||||
|
try:
|
||||||
|
request.user.person
|
||||||
|
except Person.DoesNotExist:
|
||||||
|
return render(request, 'registration/missing_person.html')
|
||||||
|
return f(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@decorator
|
||||||
|
def require_api_key(f, request, *args, **kwargs):
|
||||||
|
|
||||||
|
def err(code, text):
|
||||||
|
return HttpResponse(text, status=code, content_type='text/plain')
|
||||||
|
# Check method and get hash
|
||||||
|
if request.method == 'POST':
|
||||||
|
hash = request.POST.get('apikey')
|
||||||
|
elif request.method == 'GET':
|
||||||
|
hash = request.GET.get('apikey')
|
||||||
|
else:
|
||||||
|
return err(405, "Method not allowed")
|
||||||
|
if not hash:
|
||||||
|
return err(400, "Missing apikey parameter")
|
||||||
|
# Check hash
|
||||||
|
key = PersonalApiKey.validate_key(hash)
|
||||||
|
if not key:
|
||||||
|
return err(400, "Invalid apikey")
|
||||||
|
# Check endpoint
|
||||||
|
urlpath = request.META.get('PATH_INFO')
|
||||||
|
if not (urlpath and urlpath == key.endpoint):
|
||||||
|
return err(400, "Apikey endpoint mismatch")
|
||||||
|
# Check time since regular login
|
||||||
|
person = key.person
|
||||||
|
last_login = person.user.last_login
|
||||||
|
time_limit = (datetime.datetime.now() - datetime.timedelta(days=settings.UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS))
|
||||||
|
if last_login == None or last_login < time_limit:
|
||||||
|
return err(400, "Too long since last regular login")
|
||||||
|
# Log in
|
||||||
|
login(request, person.user)
|
||||||
|
# restore the user.last_login field, so it reflects only gui logins
|
||||||
|
person.user.last_login = last_login
|
||||||
|
person.user.save()
|
||||||
|
# Update stats
|
||||||
|
key.count += 1
|
||||||
|
key.latest = datetime.datetime.now()
|
||||||
|
key.save()
|
||||||
|
PersonApiKeyEvent.objects.create(person=person, type='apikey_login', key=key, desc="Logged in with key ID %s, endpoint %s" % (key.id, key.endpoint))
|
||||||
|
# Execute decorated function
|
||||||
|
return f(request, *args, **kwargs)
|
||||||
|
|
51
ietf/utils/management/commands/send_apikey_usage_emails.py
Normal file
51
ietf/utils/management/commands/send_apikey_usage_emails.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright The IETF Trust 2017, All Rights Reserved
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
import debug # pyflakes:ignore
|
||||||
|
|
||||||
|
from ietf.person.models import PersonalApiKey, PersonApiKeyEvent
|
||||||
|
from ietf.utils.mail import send_mail
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Send out emails to all persons who have personal API keys about usage.
|
||||||
|
|
||||||
|
Usage is show over the given period, where the default period is 7 days.
|
||||||
|
"""
|
||||||
|
|
||||||
|
help = dedent(__doc__).strip()
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('-d', '--days', dest='days', type=int, default=7,
|
||||||
|
help='The period over which to show usage.')
|
||||||
|
|
||||||
|
def handle(self, *filenames, **options):
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.verbosity = int(options.get('verbosity'))
|
||||||
|
days = options.get('days')
|
||||||
|
|
||||||
|
keys = PersonalApiKey.objects.filter(valid=True)
|
||||||
|
for key in keys:
|
||||||
|
earliest = datetime.datetime.now() - datetime.timedelta(days=days)
|
||||||
|
events = PersonApiKeyEvent.objects.filter(key=key, time__gt=earliest)
|
||||||
|
count = events.count()
|
||||||
|
events = events[:32]
|
||||||
|
if count:
|
||||||
|
key_name = key.hash()[:8]
|
||||||
|
subject = "API key usage for key '%s' for the last %s days" %(key_name, days)
|
||||||
|
to = key.person.email_address()
|
||||||
|
frm = settings.DEFAULT_FROM_EMAIL
|
||||||
|
send_mail(None, to, frm, subject, 'utils/apikey_usage_report.txt', {'person':key.person,
|
||||||
|
'days':days, 'key':key, 'key_name':key_name, 'count':count, 'events':events, } )
|
||||||
|
|
Loading…
Reference in a new issue