diff --git a/PLAN b/PLAN index 6659fff51..1cd1f75a9 100644 --- a/PLAN +++ b/PLAN @@ -9,13 +9,10 @@ Planned work in rough order * Revisit the review tool, work through the accumulated tickets. -* Introduce an API for Meetecho to use to associate recordings with sessions - (and perhaps automate making copies of those videos) - * GroupFeatures cleanup. Move most to fields on GroupTypeName, and fix places that still uses lists of group types to determine actions by instead defining group type fields to hold the selector. (Setting up a new - group type for things like PechaCucha and Hot RFC Lightning Tals probably + group type for things like PechaCucha and Hot RFC Lightning Talks probably could have been done with only table edits if there hadn't been so much code using type_id lists and features needing code changes). diff --git a/ietf/api/tests.py b/ietf/api/tests.py index a5a2caf91..b93eeaa9b 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -1,20 +1,27 @@ +# Copyright The IETF Trust 2015-2018, All Rights Reserved + +import json import os import sys -import json + from importlib import import_module from mock import patch from django.apps import apps -from django.test import Client from django.conf import settings +from django.test import Client from django.urls import reverse as urlreverse +from django.utils import timezone from tastypie.test import ResourceTestCaseMixin import debug # pyflakes:ignore -from ietf.utils.test_utils import TestCase +from ietf.group.factories import RoleFactory +from ietf.meeting.factories import MeetingFactory, SessionFactory from ietf.meeting.test_data import make_meeting_test_data +from ietf.person.models import PersonalApiKey +from ietf.utils.test_utils import TestCase OMITTED_APPS = ( 'ietf.secr.meetings', @@ -44,6 +51,75 @@ class CustomApiTestCase(TestCase): r = self.client.get(url) self.assertContains(r, 'The datatracker API', status_code=200) + def test_api_set_session_video_url(self): + url = urlreverse('ietf.meeting.views.api_set_session_video_url') + recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') + recman = recmanrole.person + meeting = MeetingFactory(type_id='ietf') + session = SessionFactory(group__type_id='wg', meeting=meeting) + group = session.group + apikey = PersonalApiKey.objects.create(endpoint=url, person=recman) + video = 'https://foo.example.com/bar/beer/' + + # error cases + r = self.client.post(url, {}) + self.assertContains(r, "Missing apikey parameter", status_code=400) + + badrole = RoleFactory(group__type_id='ietf', name_id='ad') + badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person) + badrole.person.user.last_login = timezone.now() + badrole.person.user.save() + r = self.client.post(url, {'apikey': badapikey.hash()} ) + self.assertContains(r, "Restricted to role Recording Manager", status_code=403) + + r = self.client.post(url, {'apikey': apikey.hash()} ) + self.assertContains(r, "Too long since last regular login", status_code=400) + recman.user.last_login = timezone.now() + recman.user.save() + + r = self.client.get(url, {'apikey': apikey.hash()} ) + self.assertContains(r, "Method not allowed", status_code=405) + + r = self.client.post(url, {'apikey': apikey.hash()} ) + self.assertContains(r, "Missing meeting parameter", status_code=400) + + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': meeting.number, } ) + self.assertContains(r, "Missing group parameter", status_code=400) + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': meeting.number, 'group': group.acronym} ) + self.assertContains(r, "Missing item parameter", status_code=400) + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': meeting.number, 'group': group.acronym, 'item': '1'} ) + self.assertContains(r, "Missing url parameter", status_code=400) + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': '1', 'group': group.acronym, + 'item': '1', 'url': video, }) + self.assertContains(r, "No sessions found for meeting", status_code=404) + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': meeting.number, 'group': 'bogous', + 'item': '1', 'url': video, }) + self.assertContains(r, "No sessions found in meeting '%s' for group 'bogous'"%meeting.number, status_code=404) + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': meeting.number, 'group': group.acronym, + 'item': '1', 'url': "foobar", }) + self.assertContains(r, "Invalid url value: 'foobar'", status_code=400) + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': meeting.number, 'group': group.acronym, + 'item': '5', 'url': video, }) + self.assertContains(r, "No item '5' found in list of sessions for group", status_code=400) + + r = self.client.post(url, {'apikey': apikey.hash(), 'meeting': meeting.number, 'group': group.acronym, + 'item': '1', 'url': video, }) + self.assertContains(r, "Done", status_code=200) + recordings = session.recordings() + self.assertEqual(len(recordings), 1) + doc = recordings[0] + self.assertEqual(doc.external_url, video) + event = doc.latest_event() + self.assertEqual(event.by, recman) + + class TastypieApiTestCase(ResourceTestCaseMixin, TestCase): def __init__(self, *args, **kwargs): self.apps = {} diff --git a/ietf/api/urls.py b/ietf/api/urls.py index b55fd412a..8f29e2a5e 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ url(r'^v1/?$', api_views.top_level), # Custom API endpoints url(r'^notify/meeting/import_recordings/(?P[a-z0-9-]+)/?$', meeting_views.api_import_recordings), + url(r'^meeting/session/video/url$', meeting_views.api_set_session_video_url), url(r'^submit/?$', submit_views.api_submit), url(r'^iesg/position', views_ballot.api_set_position), ] diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index 617354323..20d820d57 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -71,6 +71,7 @@ def has_role(user, role_names, *args, **kwargs): "Nomcom": Q(person=person, group__type="nomcom", group__acronym__icontains=kwargs.get('year', '0000')), "Liaison Manager": Q(person=person,name="liaiman",group__type="sdo",group__state="active", ), "Authorized Individual": Q(person=person,name="auth",group__type="sdo",group__state="active", ), + "Recording Manager": Q(person=person,name="recman",group__type="ietf",group__state="active", ), "Reviewer": Q(person=person, name="reviewer", group__state="active"), "Review Team Secretary": Q(person=person, name="secr", group__reviewteamsettings__isnull=False,group__state="active", ), diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index d3976f35b..466f55e74 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007, All Rights Reserved +# Copyright The IETF Trust 2007-2018, All Rights Reserved import csv import datetime @@ -23,6 +23,8 @@ from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidde from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator from django.urls import reverse,reverse_lazy from django.db.models import Min, Max, Q from django.forms.models import modelform_factory, inlineformset_factory @@ -59,7 +61,8 @@ from ietf.meeting.helpers import send_interim_announcement_request from ietf.meeting.utils import finalize from ietf.secr.proceedings.utils import handle_upload_file from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files, - import_youtube_video_urls) + import_youtube_video_urls, create_recording) +from ietf.utils.decorators import require_api_key from ietf.utils.mail import send_mail_message from ietf.utils.pipe import pipe from ietf.utils.pdf import pdf_pages @@ -2180,6 +2183,62 @@ def api_import_recordings(request, number): else: return HttpResponse(status=405) +@require_api_key +@role_required('Recording Manager') +@csrf_exempt +def api_set_session_video_url(request): + def err(code, text): + return HttpResponse(text, status=code, content_type='text/plain') + if request.method == 'POST': + # parameters: + # apikey: the poster's personal API key + # meeting: '101', or 'interim-2018-quic-02' + # group: 'quic' or 'plenary' + # item: '1', '2', '3' (the group's first, second, third etc. + # session during the week) + # url: The recording url (on YouTube, or whatever) + user = request.user.person + for item in ['meeting', 'group', 'item', 'url',]: + value = request.POST.get(item) + if not value: + return err(400, "Missing %s parameter" % item) + number = request.POST.get('meeting') + sessions = Session.objects.filter(meeting__number=number) + if not sessions.exists(): + return err(404, "No sessions found for meeting '%s'" % (number, )) + acronym = request.POST.get('group') + sessions = sessions.filter(group__acronym=acronym) + if not sessions.exists(): + return err(404, "No sessions found in meeting '%s' for group '%s'" % (number, acronym)) + session_times = [ (s.official_timeslotassignment().timeslot.time, s) for s in sessions ] + session_times.sort() + item = request.POST.get('item') + if not item.isdigit(): + return err(400, "Expected a numeric value for 'item', found '%s'" % (item, )) + n = int(item)-1 # change 1-based to 0-based + try: + time, session = session_times[n] + except IndexError: + return err(400, "No item '%s' found in list of sessions for group" % (item, )) + url = request.POST.get('url') + try: + URLValidator()(url) + except ValidationError: + return err(400, "Invalid url value: '%s'" % (url, )) + recordings = [ (r.name, r.title, r) for r in session.recordings() if 'video' in r.title.lower() ] + if recordings: + r = recordings[-1][-1] + r.external_url = url + else: + time = session.official_timeslotassignment().timeslot.time + title = 'Video recording for %s on %s at %s' % (acronym, time.date(), time.time()) + create_recording(session, url, title=title, user=user) + else: + return err(405, "Method not allowed") + + return HttpResponse("Done", status=200, content_type='text/plain') + + def important_dates(request, num=None): assert num is None or num.isdigit() preview_roles = ['Area Director', 'Secretariat', 'IETF Chair', 'IAD', ] diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 56b26c681..0fb304e9f 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -9538,6 +9538,16 @@ "model": "name.rolename", "pk": "pre-ad" }, + { + "fields": { + "desc": "", + "name": "Recording Manager", + "order": 13, + "used": true + }, + "model": "name.rolename", + "pk": "recman" + }, { "fields": { "desc": "", @@ -9578,6 +9588,16 @@ "model": "name.rolename", "pk": "trac-admin" }, + { + "fields": { + "desc": "Provides log-in permission to restricted Trac instances", + "name": "Trac Editor", + "order": 0, + "used": true + }, + "model": "name.rolename", + "pk": "trac-editor" + }, { "fields": { "desc": "Audio streaming support", @@ -10002,7 +10022,7 @@ "fields": { "command": "xym", "switch": "--version", - "time": "2017-12-31T00:07:14.314", + "time": "2018-03-23T00:08:43.130", "used": true, "version": "xym 0.4" }, @@ -10013,9 +10033,9 @@ "fields": { "command": "pyang", "switch": "--version", - "time": "2017-12-31T00:07:15.241", + "time": "2018-03-23T00:08:44.177", "used": true, - "version": "pyang 1.7.3" + "version": "pyang 1.7.4" }, "model": "utils.versioninfo", "pk": 2 @@ -10024,11 +10044,22 @@ "fields": { "command": "yanglint", "switch": "--version", - "time": "2017-12-31T00:07:15.325", + "time": "2018-03-23T00:08:44.295", "used": true, - "version": "yanglint 0.14.53" + "version": "yanglint 0.14.73" }, "model": "utils.versioninfo", "pk": 3 + }, + { + "fields": { + "command": "xml2rfc", + "switch": "--version", + "time": "2018-03-23T00:08:45.862", + "used": true, + "version": "xml2rfc 2.9.6" + }, + "model": "utils.versioninfo", + "pk": 4 } ] diff --git a/ietf/person/models.py b/ietf/person/models.py index 29a596881..886802b83 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -290,6 +290,7 @@ def salt(): # Manual maintenance: List all endpoints that use @require_api_key here PERSON_API_KEY_ENDPOINTS = [ ("/api/iesg/position", "/api/iesg/position"), + ("/api/meeting/session/video/url", "/api/meeting/session/video/url"), ] class PersonalApiKey(models.Model): @@ -304,7 +305,10 @@ class PersonalApiKey(models.Model): @classmethod def validate_key(cls, s): import struct, hashlib, base64 - key = base64.urlsafe_b64decode(six.binary_type(s)) + try: + key = base64.urlsafe_b64decode(six.binary_type(s)) + except TypeError: + return None id, salt, hash = struct.unpack(KEY_STRUCT, key) k = cls.objects.filter(id=id) if not k.exists(): diff --git a/ietf/secr/proceedings/proc_utils.py b/ietf/secr/proceedings/proc_utils.py index 8e11356bc..75f67af96 100644 --- a/ietf/secr/proceedings/proc_utils.py +++ b/ietf/secr/proceedings/proc_utils.py @@ -177,7 +177,7 @@ def get_or_create_recording_document(url,session): except ObjectDoesNotExist: return create_recording(session,url) -def create_recording(session,url): +def create_recording(session, url, title=None, user=None): ''' Creates the Document type=recording, setting external_url and creating NewRevisionDocEvent @@ -185,10 +185,11 @@ def create_recording(session,url): sequence = get_next_sequence(session.group,session.meeting,'recording') name = 'recording-{}-{}-{}'.format(session.meeting.number,session.group.acronym,sequence) time = session.official_timeslotassignment().timeslot.time.strftime('%Y-%m-%d %H:%M') - if url.endswith('mp3'): - title = 'Audio recording for {}'.format(time) - else: - title = 'Video recording for {}'.format(time) + if not title: + if url.endswith('mp3'): + title = 'Audio recording for {}'.format(time) + else: + title = 'Video recording for {}'.format(time) doc = Document.objects.create(name=name, title=title, @@ -202,7 +203,7 @@ def create_recording(session,url): # create DocEvent NewRevisionDocEvent.objects.create(type='new_revision', - by=Person.objects.get(name='(System)'), + by=user or Person.objects.get(name='(System)'), doc=doc, rev=doc.rev, desc='New revision available', diff --git a/ietf/settings.py b/ietf/settings.py index 8f146cfca..238bf5ede 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -18,6 +18,7 @@ warnings.filterwarnings("ignore", message="on_delete will be a required arg for warnings.filterwarnings("ignore", message="The load_template\(\) method is deprecated. Use get_template\(\) instead.") warnings.filterwarnings("ignore", message="escape isn't the last filter in") warnings.filterwarnings("ignore", message="Deprecated allow_tags attribute used on field") +warnings.filterwarnings("ignore", message="You passed a bytestring as `filenames`. This will not work on Python 3.") try: import syslog diff --git a/ietf/templates/api/index.html b/ietf/templates/api/index.html index 527ff69fb..ed32ca5ee 100644 --- a/ietf/templates/api/index.html +++ b/ietf/templates/api/index.html @@ -226,6 +226,45 @@ Done + + +

Set session video URL

+ +

+ + This interface is intended for Meetecho, to provide a way to set the + URL of a video recording for a given session. It is available at + {% url 'ietf.meeting.views.api_set_session_video_url' %}. + Access is limited to recording managers. + +

+

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

+

+ The ballot position API takes the following parameters: +

+ +

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

+

+ Here is an example: +

+
+      $ curl -S -F "apikey=DgAAAMLSi3coaE5TjrRs518xO8eBRlCmFF3eQcC8_SjUTtRGLGiJh7-1SYPT5WiS" -F "meeting=101" -F "group=mptcp" -F "item=1" -F "url=https://foo.example/beer/mptcp" https://datatracker.ietf.org/api/meeting/session/video/url
+      Done
+    
+ + + {% endblock %}