diff --git a/bin/daily b/bin/daily index 870873076..3e14c899a 100755 --- a/bin/daily +++ b/bin/daily @@ -3,8 +3,7 @@ # Nightly datatracker jobs. # # This script is expected to be triggered by cron from -# $DTDIR/etc/cron.d/datatracker which should be symlinked from -# /etc/cron.d/ +# /etc/cron.d/datatracker # Run the hourly jobs first $DTDIR/bin/hourly diff --git a/bin/hourly b/bin/hourly index e73a51a8f..e9f496b7c 100755 --- a/bin/hourly +++ b/bin/hourly @@ -3,8 +3,7 @@ # Hourly datatracker jobs # # This script is expected to be triggered by cron from -# $DTDIR/etc/cron.d/datatracker which should be symlinked from -# /etc/cron.d/ +# /etc/cron.d/datatracker DTDIR=/a/www/ietf-datatracker/web cd $DTDIR/ diff --git a/bin/monthly b/bin/monthly new file mode 100755 index 000000000..5c15827e1 --- /dev/null +++ b/bin/monthly @@ -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" + diff --git a/bin/weekly b/bin/weekly new file mode 100755 index 000000000..e5ab321fb --- /dev/null +++ b/bin/weekly @@ -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 + diff --git a/ietf/api/urls.py b/ietf/api/urls.py index 365857333..b55fd412a 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -4,6 +4,7 @@ from django.conf.urls import include from ietf import api from ietf.api import views as api_views +from ietf.doc import views_ballot from ietf.meeting import views as meeting_views from ietf.submit import views as submit_views from ietf.utils.urls import url @@ -17,6 +18,7 @@ urlpatterns = [ # Custom API endpoints url(r'^notify/meeting/import_recordings/(?P[a-z0-9-]+)/?$', meeting_views.api_import_recordings), url(r'^submit/?$', submit_views.api_submit), + url(r'^iesg/position', views_ballot.api_set_position), ] # Additional (standard) Tastypie endpoints diff --git a/ietf/doc/tests_ballot.py b/ietf/doc/tests_ballot.py index f4de9ca33..8f039b19c 100644 --- a/ietf/doc/tests_ballot.py +++ b/ietf/doc/tests_ballot.py @@ -13,7 +13,7 @@ from ietf.doc.utils import create_ballot_if_not_open from ietf.group.models import Group, Role from ietf.name.models import BallotPositionName 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.mail import outbox, empty_outbox 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.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): draft = make_test_data() ad = Person.objects.get(user__username="ad") diff --git a/ietf/doc/views_ballot.py b/ietf/doc/views_ballot.py index e66b0c748..d6ec3f7c8 100644 --- a/ietf/doc/views_ballot.py +++ b/ietf/doc/views_ballot.py @@ -3,12 +3,15 @@ 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.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 @@ -30,6 +33,7 @@ from ietf.name.models import BallotPositionName from ietf.person.models import Person from ietf.utils import log from ietf.utils.mail import send_mail_text, send_mail_preformatted +from ietf.utils.decorators import require_api_key BALLOT_CHOICES = (("yes", "Yes"), ("noobj", "No Objection"), @@ -106,6 +110,74 @@ class EditPositionForm(forms.Form): raise forms.ValidationError("You must enter a non-empty 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') def edit_position(request, name, ballot_id): """Vote and edit discuss and comment on document as Area Director.""" @@ -126,8 +198,6 @@ def edit_position(request, name, ballot_id): raise Http404 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 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 @@ -135,68 +205,7 @@ def edit_position(request, name, ballot_id): form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type) if form.is_valid(): - # save the vote - 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 + save_position(form, doc, ballot, ad, login) if request.POST.get("send_mail"): qstr="" @@ -211,6 +220,7 @@ def edit_position(request, name, ballot_id): return HttpResponseRedirect(return_to_url) else: initial = {} + old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", ad=ad, ballot=ballot) if old_pos: initial['position'] = old_pos.pos.slug initial['discuss'] = old_pos.discuss @@ -234,6 +244,45 @@ def edit_position(request, name, ballot_id): 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') def send_ballot_comment(request, name, ballot_id): """Email document ballot position discuss/comment for Area Director.""" diff --git a/ietf/ietfauth/management/__init__.py b/ietf/ietfauth/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/ietfauth/management/commands/__init__.py b/ietf/ietfauth/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/ietfauth/management/commands/send_apikey_usage_emails.py b/ietf/ietfauth/management/commands/send_apikey_usage_emails.py new file mode 100644 index 000000000..2718ef02a --- /dev/null +++ b/ietf/ietfauth/management/commands/send_apikey_usage_emails.py @@ -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, } ) + diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index abf3ec027..fba73e529 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -1,5 +1,6 @@ +# Copyright The IETF Trust 2017, All Rights Reserved # -*- coding: utf-8 -*- -from __future__ import unicode_literals +from __future__ import unicode_literals, print_function import os, shutil, time, datetime 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_data import make_test_data, make_review_data 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.factories import GroupFactory from ietf.ietfauth.htpasswd import update_htpasswd_file 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.utils.decorators import skip_coverage @@ -99,7 +102,7 @@ class IetfAuthTests(TestCase): if l.startswith(username + ":"): return True with open(settings.HTPASSWD_FILE) as f: - print f.read() + print(f.read()) return False @@ -495,3 +498,145 @@ class IetfAuthTests(TestCase): user = User.objects.get(username="othername@example.org") self.assertEqual(prev, user) 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) + diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index 5efbb4b38..fb1f84db5 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -7,6 +7,9 @@ from ietf.utils.urls import url urlpatterns = [ 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[^/]+)/$', views.confirm_new_email), url(r'^create/$', views.create_account), url(r'^create/confirm/(?P[^/]+)/$', views.confirm_account), diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 8739766ff..e3f549279 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -48,6 +48,7 @@ from django.contrib.auth.hashers import identify_hasher from django.contrib.auth.models import User from django.contrib.auth.views import login as django_login from django.contrib.sites.models import Site +from django.core.validators import ValidationError from django.urls import reverse as urlreverse from django.http import Http404, HttpResponseRedirect #, HttpResponse, 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.utils import role_required 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.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.utils.decorators import person_required +from ietf.utils.mail import send_mail def index(request): return render(request, 'registration/index.html') @@ -190,14 +192,10 @@ def confirm_account(request, auth): }) @login_required +@person_required def profile(request): roles = [] - person = None - - try: - person = request.user.person - except Person.DoesNotExist: - return render(request, 'registration/missing_person.html') + person = request.user.person 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') @@ -533,13 +531,9 @@ def change_password(request): @login_required +@person_required def change_username(request): - person = None - - try: - person = request.user.person - except Person.DoesNotExist: - return render(request, 'registration/missing_person.html') + person = request.user.person 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) ] @@ -599,3 +593,61 @@ def login(request, extra_context=None): } 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'}) diff --git a/ietf/person/admin.py b/ietf/person/admin.py index c50536280..86c0c851f 100644 --- a/ietf/person/admin.py +++ b/ietf/person/admin.py @@ -1,7 +1,7 @@ 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 class EmailAdmin(admin.ModelAdmin): @@ -43,4 +43,21 @@ class PersonHistoryAdmin(admin.ModelAdmin): search_fields = ['name', 'ascii'] 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) diff --git a/ietf/person/migrations/0021_personalapikey.py b/ietf/person/migrations/0021_personalapikey.py new file mode 100644 index 000000000..e3e9e4bc3 --- /dev/null +++ b/ietf/person/migrations/0021_personalapikey.py @@ -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'), + ), + ] diff --git a/ietf/person/models.py b/ietf/person/models.py index 217720fbe..64cf5884d 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -3,6 +3,9 @@ import datetime import email.utils import email.header +import six +import uuid + from hashids import Hashids from urlparse import urljoin @@ -274,3 +277,71 @@ class Email(models.Model): return 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) + diff --git a/ietf/person/resources.py b/ietf/person/resources.py index 11ef747bf..4cd0ce24a 100644 --- a/ietf/person/resources.py +++ b/ietf/person/resources.py @@ -6,7 +6,7 @@ from tastypie.cache import SimpleCache 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 @@ -82,3 +82,61 @@ class PersonHistoryResource(ModelResource): "user": ALL_WITH_RELATIONS, } 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()) diff --git a/ietf/settings.py b/ietf/settings.py index e752845f6..4b802f828 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -932,6 +932,7 @@ SILENCED_SYSTEM_CHECKS = [ STATS_NAMES_LIMIT = 25 UTILS_TEST_RANDOM_STATE_FILE = '.factoryboy_random_state' +UTILS_APIKEY_GUI_LOGIN_LIMIT_DAYS = 30 API_PUBLIC_KEY_PEM = """ -----BEGIN PUBLIC KEY----- diff --git a/ietf/templates/api/index.html b/ietf/templates/api/index.html index 431bdcf2c..bc5374001 100644 --- a/ietf/templates/api/index.html +++ b/ietf/templates/api/index.html @@ -8,6 +8,11 @@

Framework

+

+ This section describes the autogenrated read-only API towards the database tables. See also + the draft submission API description and the + IESG ballot position API description +

The datatracker API uses tastypie 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 2017), but may do so in the future. If so, personal API keys will be - available from your Account Profile page when you are logged in, and document keys + available from your + Account API Key page when you are logged in, and document keys will be visible to document authors on the document status page when logged in. @@ -186,8 +191,38 @@

{{key.export_to_pem}}
-
+

IESG ballot position API

+

+ A simplified IESG ballot position interface, intended for automation, + is available at {% url 'ietf.doc.views_ballot.api_set_position' %}. +

+

+ The interface requires the use of a personal API key, which can be created at + {% url 'ietf.ietfauth.views.apikey_index' %} +

+

+ It takes the following parameters: +

+
    +
  • apikey (required) which is the personal API key hash
  • +
  • doc (required) which is the balloted document name
  • +
  • position (required) which is the position slug, one of: yes, noobj, block, discuss, abstain, recuse, norecord
  • +
  • discuss (required if position is 'discuss') which is the discuss text
  • +
  • comment (optional) which is comment text
  • +
+

+ It returns an appropriate http result code, and a brief explanatory text message. +

+

+ Here is an example: +

+
+      $ 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
+    
+ + {% endblock %} diff --git a/ietf/templates/base.html b/ietf/templates/base.html index 0d5f7bc97..1287563b3 100644 --- a/ietf/templates/base.html +++ b/ietf/templates/base.html @@ -108,7 +108,7 @@ {% endif %} - {% block content %}{% endblock %} + {% block content %}{{ content|safe }}{% endblock %} {% block content_end %}{% endblock %} {% if request.COOKIES.left_menu != "off" and not hide_menu %} diff --git a/ietf/templates/base/menu_user.html b/ietf/templates/base/menu_user.html index f3f0d07b2..47364c8de 100644 --- a/ietf/templates/base/menu_user.html +++ b/ietf/templates/base/menu_user.html @@ -18,6 +18,7 @@
  • Sign out
  • Account info
  • Preferences
  • +
  • API keys
  • Change password
  • Change username
  • {% else %} diff --git a/ietf/templates/form.html b/ietf/templates/form.html new file mode 100644 index 000000000..a15340209 --- /dev/null +++ b/ietf/templates/form.html @@ -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 %} + +{% endblock %} + +{% block content %} + {% origin %} +

    {{ title|safe }}

    + +

    + {{ description|safe }} +

    + +
    + {% csrf_token %} + + {% bootstrap_form form %} + + {% buttons %} + + {% endbuttons %} +
    +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/ietf/templates/ietfauth/apikeys.html b/ietf/templates/ietfauth/apikeys.html new file mode 100644 index 000000000..e4fe20ed4 --- /dev/null +++ b/ietf/templates/ietfauth/apikeys.html @@ -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 %} +

    API keys for {{ user.username }}

    + + {% csrf_token %} +
    + +
    +
    + + {% for key in person.apikeys.all %} + {% if forloop.first %} + + {% endif %} + + + + + + + + + + + + {% empty %} + + + {% endfor %} +
    EndpointCreatedLatest useCountValid
    {{ key.endpoint }} {{ key.created }} {{ key.latest }} {{ key.count }} {{ key.valid }}
    {{ key.hash }} + {% if key.valid %} + Disable + {% endif %} +
    You have no personal API keys.
    + Get a new personal API key +
    +
    + +
    + +{% endblock %} diff --git a/ietf/templates/person/profile.html b/ietf/templates/person/profile.html index 5746b350e..a10f61f3d 100644 --- a/ietf/templates/person/profile.html +++ b/ietf/templates/person/profile.html @@ -34,8 +34,8 @@ -
    - {% if person.role_set.exists %} + {% if person.role_set.exists %} +

    Roles

    {% for role in person.role_set.all|active_roles %} @@ -54,8 +54,10 @@ {{ person.first_name }} has no active roles as of {{ today }}. {% endfor %}
    - {% endif %} -
    +
    + {% endif %} + +

    RFCs

    diff --git a/ietf/templates/utils/apikey_usage_report.txt b/ietf/templates/utils/apikey_usage_report.txt new file mode 100644 index 000000000..a3b7bfd7b --- /dev/null +++ b/ietf/templates/utils/apikey_usage_report.txt @@ -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 %} diff --git a/ietf/utils/decorators.py b/ietf/utils/decorators.py index 2718d4ec2..ef7428664 100644 --- a/ietf/utils/decorators.py +++ b/ietf/utils/decorators.py @@ -1,10 +1,18 @@ # Copyright The IETF Trust 2016, All Rights Reserved +import datetime + from decorator import decorator 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 def skip_coverage(f, *args, **kwargs): @@ -15,3 +23,54 @@ def skip_coverage(f, *args, **kwargs): return result else: 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) diff --git a/ietf/utils/management/commands/send_apikey_usage_emails.py b/ietf/utils/management/commands/send_apikey_usage_emails.py new file mode 100644 index 000000000..2718ef02a --- /dev/null +++ b/ietf/utils/management/commands/send_apikey_usage_emails.py @@ -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, } ) +