From 7287e9870935e2b5345518df35747d8dbf4cdba9 Mon Sep 17 00:00:00 2001
From: Robert Sparks <rjsparks@nostrum.com>
Date: Mon, 4 Mar 2024 16:48:02 -0600
Subject: [PATCH] feat: upload narrative minutes (#7125)

* feat: upload narrative minutes

* chore: cover other new URL path
---
 ietf/meeting/forms.py                         |  3 +
 ietf/meeting/models.py                        |  1 -
 ietf/meeting/tests_views.py                   | 27 +++++++++
 ietf/meeting/urls.py                          |  1 +
 ietf/meeting/utils.py                         | 25 ++++----
 ietf/meeting/views.py                         | 58 ++++++++++++++++++-
 ietf/name/fixtures/names.json                 | 54 +++++++++++++++--
 ietf/settings.py                              |  2 +-
 .../meeting/session_details_panel.html        |  8 +++
 .../upload_session_narrativeminutes.html      | 34 +++++++++++
 10 files changed, 192 insertions(+), 21 deletions(-)
 create mode 100644 ietf/templates/meeting/upload_session_narrativeminutes.html

diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py
index 164f0fd3b..2cec669db 100644
--- a/ietf/meeting/forms.py
+++ b/ietf/meeting/forms.py
@@ -472,6 +472,9 @@ class ApplyToAllFileUploadForm(FileUploadForm):
 class UploadMinutesForm(ApplyToAllFileUploadForm):
     doc_type = 'minutes'
 
+class UploadNarrativeMinutesForm(ApplyToAllFileUploadForm):
+    doc_type = 'narrativeminutes'
+
 
 class UploadAgendaForm(ApplyToAllFileUploadForm):
     doc_type = 'agenda'
diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py
index 4689495e0..781ced787 100644
--- a/ietf/meeting/models.py
+++ b/ietf/meeting/models.py
@@ -1147,7 +1147,6 @@ class Session(models.Model):
         return can_manage_materials(user,self.group)
 
     def is_material_submission_cutoff(self):
-        debug.say("is_material_submission_cutoff got called")
         return date_today(datetime.timezone.utc) > self.meeting.get_submission_correction_date()
     
     def joint_with_groups_acronyms(self):
diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py
index e2abcede8..092f8be89 100644
--- a/ietf/meeting/tests_views.py
+++ b/ietf/meeting/tests_views.py
@@ -6233,6 +6233,33 @@ class MaterialsTests(TestCase):
             self.requests_mock.get(f'{session.notes_url()}/info', text=json.dumps({'title': 'title', 'updatetime': '2021-12-01T17:11:00z'}))
             self.crawl_materials(url=url, top=top)
 
+    @override_settings(MEETING_MATERIALS_SERVE_LOCALLY=True)
+    def test_upload_narrativeminutes(self):
+        for type_id in ["interim","ietf"]:
+            session=SessionFactory(meeting__type_id=type_id,group__acronym='iesg')
+            doctype='narrativeminutes'
+            url = urlreverse('ietf.meeting.views.upload_session_narrativeminutes',kwargs={'num':session.meeting.number,'session_id':session.id})
+            self.client.logout()
+            login_testing_unauthorized(self,"secretary",url)
+            r = self.client.get(url)
+            self.assertEqual(r.status_code, 200)
+            q = PyQuery(r.content)
+            self.assertIn('Upload', str(q("title")))
+            self.assertFalse(session.presentations.filter(document__type_id=doctype))
+            test_file = BytesIO(b'this is some text for a test')
+            test_file.name = "not_really.txt"
+            r = self.client.post(url,dict(submission_method="upload",file=test_file))
+            self.assertEqual(r.status_code, 302)
+            doc = session.presentations.filter(document__type_id=doctype).first().document
+            self.assertEqual(doc.rev,'00')
+
+            # Verify that we don't have dead links
+            url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':session.meeting.number, 'acronym': session.group.acronym})
+            top = '/meeting/%s/' % session.meeting.number
+            self.requests_mock.get(f'{session.notes_url()}/download', text='markdown notes')
+            self.requests_mock.get(f'{session.notes_url()}/info', text=json.dumps({'title': 'title', 'updatetime': '2021-12-01T17:11:00z'}))
+            self.crawl_materials(url=url, top=top)
+
     def test_enter_agenda(self):
         session = SessionFactory(meeting__type_id='ietf')
         url = urlreverse('ietf.meeting.views.upload_session_agenda',kwargs={'num':session.meeting.number,'session_id':session.id})
diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py
index 7c79a8025..1c6e49213 100644
--- a/ietf/meeting/urls.py
+++ b/ietf/meeting/urls.py
@@ -18,6 +18,7 @@ safe_for_all_meeting_types = [
     url(r'^session/(?P<session_id>\d+)/drafts$',  views.add_session_drafts),
     url(r'^session/(?P<session_id>\d+)/bluesheets$', views.upload_session_bluesheets),
     url(r'^session/(?P<session_id>\d+)/minutes$', views.upload_session_minutes),
+    url(r'^session/(?P<session_id>\d+)/narrativeminutes$', views.upload_session_narrativeminutes),
     url(r'^session/(?P<session_id>\d+)/agenda$', views.upload_session_agenda),
     url(r'^session/(?P<session_id>\d+)/import/minutes$', views.import_session_minutes),
     url(r'^session/(?P<session_id>\d+)/propose_slides$', views.propose_session_slides),
diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py
index 9fb062b02..6469dbfbb 100644
--- a/ietf/meeting/utils.py
+++ b/ietf/meeting/utils.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2016-2020, All Rights Reserved
+# Copyright The IETF Trust 2016-2024, All Rights Reserved
 # -*- coding: utf-8 -*-
 import datetime
 import itertools
@@ -555,7 +555,7 @@ class SaveMaterialsError(Exception):
     pass
 
 
-def save_session_minutes_revision(session, file, ext, request, encoding=None, apply_to_all=False):
+def save_session_minutes_revision(session, file, ext, request, encoding=None, apply_to_all=False, narrative=False):
     """Creates or updates session minutes records
 
     This updates the database models to reflect a new version. It does not handle
@@ -568,7 +568,8 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap
     Returns (Document, [DocEvents]), which should be passed to doc.save_with_history()
     if the file contents are stored successfully.
     """
-    minutes_sp = session.presentations.filter(document__type='minutes').first()
+    document_type = DocTypeName.objects.get(slug= 'narrativeminutes' if narrative else 'minutes')
+    minutes_sp = session.presentations.filter(document__type=document_type).first()
     if minutes_sp:
         doc = minutes_sp.document
         doc.rev = '%02d' % (int(doc.rev)+1)
@@ -580,28 +581,26 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap
         if not sess_time:
             raise SessionNotScheduledError
         if session.meeting.type_id=='ietf':
-            name = 'minutes-%s-%s' % (session.meeting.number,
-                                      session.group.acronym)
-            title = 'Minutes IETF%s: %s' % (session.meeting.number,
-                                            session.group.acronym)
+            name = f"{document_type.prefix}-{session.meeting.number}-{session.group.acronym}"
+            title = f"{document_type.name} IETF{session.meeting.number}: {session.group.acronym}"
             if not apply_to_all:
                 name += '-%s' % (sess_time.strftime("%Y%m%d%H%M"),)
                 title += ': %s' % (sess_time.strftime("%a %H:%M"),)
         else:
-            name = 'minutes-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M"))
-            title = 'Minutes %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M"))
+            name =f"{document_type.prefix}-{session.meeting.number}-{sess_time.strftime('%Y%m%d%H%M')}"
+            title = f"{document_type.name} {session.meeting.number}: {sess_time.strftime('%a %H:%M')}"
         if Document.objects.filter(name=name).exists():
             doc = Document.objects.get(name=name)
             doc.rev = '%02d' % (int(doc.rev)+1)
         else:
             doc = Document.objects.create(
                 name = name,
-                type_id = 'minutes',
+                type = document_type,
                 title = title,
                 group = session.group,
                 rev = '00',
             )
-        doc.states.add(State.objects.get(type_id='minutes',slug='active'))
+        doc.states.add(State.objects.get(type_id=document_type.slug,slug='active'))
         if session.presentations.filter(document=doc).exists():
             sp = session.presentations.get(document=doc)
             sp.rev = doc.rev
@@ -611,7 +610,7 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap
     if apply_to_all:
         for other_session in get_meeting_sessions(session.meeting.number, session.group.acronym):
             if other_session != session:
-                other_session.presentations.filter(document__type='minutes').delete()
+                other_session.presentations.filter(document__type=document_type).delete()
                 other_session.presentations.create(document=doc,rev=doc.rev)
     filename = f'{doc.name}-{doc.rev}{ext}'
     doc.uploaded_filename = filename
@@ -628,7 +627,7 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap
         file=file,
         filename=doc.uploaded_filename,
         meeting=session.meeting,
-        subdir='minutes',
+        subdir=document_type.slug,
         request=request,
         encoding=encoding,
     )
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index 1171f7b0b..076e2b544 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -102,7 +102,8 @@ from ietf.utils.timezone import datetime_today, date_today
 
 from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm,
     InterimCancelForm, InterimSessionInlineFormSet, RequestMinutesForm,
-    UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm)
+    UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm,
+    UploadNarrativeMinutesForm)
 
 request_summary_exclude_group_types = ['team']
 
@@ -2662,6 +2663,61 @@ def upload_session_minutes(request, session_id, num):
                    'form': form,
                   })
 
+@role_required("Secretariat")
+def upload_session_narrativeminutes(request, session_id, num):
+    # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
+    session = get_object_or_404(Session,pk=session_id)
+    if session.group.acronym != "iesg":
+        raise Http404()
+    
+    session_number = None
+    sessions = get_sessions(session.meeting.number,session.group.acronym)
+    show_apply_to_all_checkbox = len(sessions) > 1 if session.type_id == 'regular' else False
+    if len(sessions) > 1:
+       session_number = 1 + sessions.index(session)
+
+    narrativeminutes_sp = session.presentations.filter(document__type='narrativeminutes').first()
+    
+    if request.method == 'POST':
+        form = UploadNarrativeMinutesForm(show_apply_to_all_checkbox,request.POST,request.FILES)
+        if form.is_valid():
+            file = request.FILES['file']
+            _, ext = os.path.splitext(file.name)
+            apply_to_all = session.type_id == 'regular'
+            if show_apply_to_all_checkbox:
+                apply_to_all = form.cleaned_data['apply_to_all']
+
+            # Set up the new revision
+            try:
+                save_session_minutes_revision(
+                    session=session,
+                    apply_to_all=apply_to_all,
+                    file=file,
+                    ext=ext,
+                    encoding=form.file_encoding[file.name],
+                    request=request,
+                    narrative=True
+                )
+            except SessionNotScheduledError:
+                return HttpResponseGone(
+                    "Cannot receive uploads for an unscheduled session. Please check the session ID.",
+                    content_type="text/plain",
+                )
+            except SaveMaterialsError as err:
+                form.add_error(None, str(err))
+            else:
+                # no exception -- success!
+                messages.success(request, f'Successfully uploaded narrative minutes as revision {session.narrative_minutes().rev}.')
+                return redirect('ietf.meeting.views.session_details', num=num, acronym=session.group.acronym)
+    else:
+        form = UploadMinutesForm(show_apply_to_all_checkbox)
+
+    return render(request, "meeting/upload_session_narrativeminutes.html", 
+                  {'session': session,
+                   'session_number': session_number,
+                   'minutes_sp' : narrativeminutes_sp,
+                   'form': form,
+                  })
 
 class UploadOrEnterAgendaForm(UploadAgendaForm):
     ACTIONS = [
diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json
index fc46970f9..e54233eda 100644
--- a/ietf/name/fixtures/names.json
+++ b/ietf/name/fixtures/names.json
@@ -2578,6 +2578,32 @@
     "model": "doc.state",
     "pk": 177
   },
+  {
+    "fields": {
+      "desc": "",
+      "name": "Active",
+      "next_states": [],
+      "order": 0,
+      "slug": "active",
+      "type": "narrativeminutes",
+      "used": true
+    },
+    "model": "doc.state",
+    "pk": 178
+  },
+  {
+    "fields": {
+      "desc": "",
+      "name": "Deleted",
+      "next_states": [],
+      "order": 1,
+      "slug": "deleted",
+      "type": "narrativeminutes",
+      "used": true
+    },
+    "model": "doc.state",
+    "pk": 179
+  },
   {
     "fields": {
       "label": "State"
@@ -2739,6 +2765,13 @@
     "model": "doc.statetype",
     "pk": "minutes"
   },
+  {
+    "fields": {
+      "label": "State"
+    },
+    "model": "doc.statetype",
+    "pk": "narrativeminutes"
+  },
   {
     "fields": {
       "label": "State"
@@ -10763,6 +10796,17 @@
     "model": "name.doctypename",
     "pk": "minutes"
   },
+  {
+    "fields": {
+      "desc": "",
+      "name": "Narrative Minutes",
+      "order": 0,
+      "prefix": "narrative-minutes",
+      "used": true
+    },
+    "model": "name.doctypename",
+    "pk": "narrativeminutes"
+  },
   {
     "fields": {
       "desc": "",
@@ -16734,7 +16778,7 @@
     "fields": {
       "command": "xym",
       "switch": "--version",
-      "time": "2023-11-21T08:09:45.989Z",
+      "time": "2024-02-21T08:06:28.313Z",
       "used": true,
       "version": "xym 0.7.0"
     },
@@ -16745,7 +16789,7 @@
     "fields": {
       "command": "pyang",
       "switch": "--version",
-      "time": "2023-11-21T08:09:46.322Z",
+      "time": "2024-02-21T08:06:28.663Z",
       "used": true,
       "version": "pyang 2.6.0"
     },
@@ -16756,7 +16800,7 @@
     "fields": {
       "command": "yanglint",
       "switch": "--version",
-      "time": "2023-11-21T08:09:46.338Z",
+      "time": "2024-02-21T08:06:28.685Z",
       "used": true,
       "version": "yanglint SO 1.9.2"
     },
@@ -16767,9 +16811,9 @@
     "fields": {
       "command": "xml2rfc",
       "switch": "--version",
-      "time": "2023-11-21T08:09:47.251Z",
+      "time": "2024-02-21T08:06:29.492Z",
       "used": true,
-      "version": "xml2rfc 3.18.2"
+      "version": "xml2rfc 3.19.4"
     },
     "model": "utils.versioninfo",
     "pk": 4
diff --git a/ietf/settings.py b/ietf/settings.py
index 15302fee6..57e6f20bd 100644
--- a/ietf/settings.py
+++ b/ietf/settings.py
@@ -913,7 +913,7 @@ MEETING_VALID_UPLOAD_EXTENSIONS = {
 MEETING_VALID_UPLOAD_MIME_TYPES = {
     'agenda':       ['text/plain', 'text/html', 'text/markdown', 'text/x-markdown', ],
     'minutes':      ['text/plain', 'text/html', 'application/pdf', 'text/markdown', 'text/x-markdown', ],
-    'narrative-minutes': ['text/plain', 'text/html', 'application/pdf', 'text/markdown', 'text/x-markdown', ],
+    'narrativeminutes': ['text/plain', 'text/html', 'application/pdf', 'text/markdown', 'text/x-markdown', ],
     'slides':       [],
     'bluesheets':   ['application/pdf', 'text/plain', ],
     'procmaterials':['application/pdf', ],
diff --git a/ietf/templates/meeting/session_details_panel.html b/ietf/templates/meeting/session_details_panel.html
index 0e3005018..8cad89179 100644
--- a/ietf/templates/meeting/session_details_panel.html
+++ b/ietf/templates/meeting/session_details_panel.html
@@ -73,6 +73,8 @@
                                 {% if user|has_role:"Secretariat" or can_manage_materials %}
                                     {% if pres.document.type.slug == 'minutes' %}
                                         {% url 'ietf.meeting.views.upload_session_minutes' session_id=session.pk num=session.meeting.number as upload_url %}
+                                    {% elif pres.document.type.slug == 'narrativeminutes' %}
+                                        {% url 'ietf.meeting.views.upload_session_narrativeminutes' session_id=session.pk num=session.meeting.number as upload_url %}
                                     {% elif pres.document.type.slug == 'agenda' %}
                                         {% url 'ietf.meeting.views.upload_session_agenda' session_id=session.pk num=session.meeting.number as upload_url %}
                                     {% else %}
@@ -106,6 +108,12 @@
                     Upload minutes
                 </a>
             {% endif %}
+            {% if not session.type_counter.narrativeminutes and session.group.acronym == "iesg" %}
+                <a class="btn btn-primary"
+                href="{% url 'ietf.meeting.views.upload_session_narrativeminutes' session_id=session.pk num=session.meeting.number %}">
+                    Upload narrative minutes
+                </a>
+        {% endif %}           
         {% endif %}
         {% if user|has_role:"Secretariat" and not session.type_counter.bluesheets or meeting.type.slug == 'interim' and can_manage_materials and not session.type_counter.bluesheets %}
             <a class="btn btn-primary"
diff --git a/ietf/templates/meeting/upload_session_narrativeminutes.html b/ietf/templates/meeting/upload_session_narrativeminutes.html
new file mode 100644
index 000000000..d99098551
--- /dev/null
+++ b/ietf/templates/meeting/upload_session_narrativeminutes.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+{# Copyright The IETF Trust 2024, All Rights Reserved #}
+{% load origin static django_bootstrap5 tz %}
+{% block title %}
+    {% if narrativeminutes_sp %}
+        Revise
+    {% else %}
+        Upload
+    {% endif %}
+    Narrative Minutes for {{ session.meeting }} : {{ session.group.acronym }}
+{% endblock %}
+{% block content %}
+    {% origin %}
+    <h1>
+        {% if narrativeminutes_sp %}
+            Revise
+        {% else %}
+            Upload
+        {% endif %}
+        Narrative Minutes for {{ session.meeting }}
+        <br>
+        <small class="text-body-secondary">{{ session.group.acronym }}
+            {% if session.name %}: {{ session.name }}{% endif %}
+        </small>
+    </h1>
+    {% if session_number %}
+        <h2>Session {{ session_number }} : {{ session.official_timeslotassignment.timeslot.time|timezone:session.meeting.time_zone|date:"D M-d-Y Hi" }}</h2>
+    {% endif %}
+    <form enctype="multipart/form-data" method="post" class="my-3">
+        {% csrf_token %}
+        {% bootstrap_form form %}
+        <button type="submit" class="btn btn-primary">Upload</button>
+    </form>
+{% endblock %}
\ No newline at end of file