Repository: ietf-tools/datatracker

Issue:
Allow entering text directly instead of uploading a file for the agenda
### Description

It would save a step to have a text field for editing an agenda inline instead of having to first create a file on my computer and upload it.

<img width="1077" alt="image" src="https://github.com/ietf-tools/datatracker/assets/113001/6e9032bb-fa26-42b7-80fc-25786b34127e">


### Code of Conduct

- [X] I agree to follow the [IETF's Code of Conduct](https://github.com/ietf-tools/.github/blob/main/CODE_OF_CONDUCT.md)


Discussion:
We have a similar field for entering reviews - we can provide one here - but be wary - it is only really usable for cut-paste - we _don't_ have keystrokes being stored serverside (or even in a cookie) while editing, so a closed browser tab will lose any typing work.
That's perfectly fine, thanks! This will also help make quick edits to the agenda a lot easier!

Resolving patch:
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2007-2022, All Rights Reserved
+# Copyright The IETF Trust 2007-2023, All Rights Reserved
 # -*- coding: utf-8 -*-
 
 
@@ -2662,6 +2662,40 @@ def upload_session_minutes(request, session_id, num):
                   })
 
 
+class UploadOrEnterAgendaForm(UploadAgendaForm):
+    ACTIONS = [
+        ("upload", "Upload agenda"),
+        ("enter", "Enter agenda"),
+    ]
+    submission_method = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect)
+
+    content = forms.CharField(widget=forms.Textarea, required=False, strip=False, label="Agenda text")
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields["file"].required=False
+        self.order_fields(["submission_method", "file", "content"])
+
+    def clean_content(self):
+        return self.cleaned_data["content"].replace("\r", "")
+
+    def clean_file(self):
+        submission_method = self.cleaned_data.get("submission_method")
+        if submission_method == "upload":
+            return super().clean_file()
+        return None
+
+    def clean(self):
+        def require_field(f):
+            if not self.cleaned_data.get(f):
+                self.add_error(f, ValidationError("You must fill in this field."))
+
+        submission_method = self.cleaned_data.get("submission_method")
+        if submission_method == "upload":
+            require_field("file")
+        elif submission_method == "enter":
+            require_field("content")
+
 def upload_session_agenda(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)
@@ -2680,10 +2714,23 @@ def upload_session_agenda(request, session_id, num):
     agenda_sp = session.sessionpresentation_set.filter(document__type='agenda').first()
     
     if request.method == 'POST':
-        form = UploadAgendaForm(show_apply_to_all_checkbox,request.POST,request.FILES)
+        form = UploadOrEnterAgendaForm(show_apply_to_all_checkbox,request.POST,request.FILES)
         if form.is_valid():
-            file = request.FILES['file']
-            _, ext = os.path.splitext(file.name)
+            submission_method = form.cleaned_data['submission_method']
+            if submission_method == "upload":
+                file = request.FILES['file']
+                _, ext = os.path.splitext(file.name)
+            else:
+                if agenda_sp:
+                    doc = agenda_sp.document
+                    _, ext = os.path.splitext(doc.uploaded_filename)
+                else:
+                    ext = ".md"
+                fd, name = tempfile.mkstemp(suffix=ext, text=True)
+                os.close(fd)
+                with open(name, "w") as file:
+                    file.write(form.cleaned_data['content'])
+                file = open(name, "rb")
             apply_to_all = session.type.slug == 'regular'
             if show_apply_to_all_checkbox:
                 apply_to_all = form.cleaned_data['apply_to_all']
@@ -2738,7 +2785,11 @@ def upload_session_agenda(request, session_id, num):
             doc.uploaded_filename = filename
             e = NewRevisionDocEvent.objects.create(doc=doc,by=request.user.person,type='new_revision',desc='New revision available: %s'%doc.rev,rev=doc.rev)
             # The way this function builds the filename it will never trigger the file delete in handle_file_upload.
-            save_error = handle_upload_file(file, filename, session.meeting, 'agenda', request=request, encoding=form.file_encoding[file.name])
+            try:
+                encoding=form.file_encoding[file.name]
+            except AttributeError:
+                encoding=None
+            save_error = handle_upload_file(file, filename, session.meeting, 'agenda', request=request, encoding=encoding)
             if save_error:
                 form.add_error(None, save_error)
             else:
@@ -2746,7 +2797,11 @@ def upload_session_agenda(request, session_id, num):
                 messages.success(request, f'Successfully uploaded agenda as revision {doc.rev}.')
                 return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym)
     else: 
-        form = UploadAgendaForm(show_apply_to_all_checkbox, initial={'apply_to_all':session.type_id=='regular'})
+        initial={'apply_to_all':session.type_id=='regular', 'submission_method':'upload'}
+        if agenda_sp:
+            doc = agenda_sp.document
+            initial['content'] = doc.text()
+        form = UploadOrEnterAgendaForm(show_apply_to_all_checkbox, initial=initial)
 
     return render(request, "meeting/upload_session_agenda.html", 
                   {'session': session,


Test patch:
diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py
--- a/ietf/meeting/tests_views.py
+++ b/ietf/meeting/tests_views.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2009-2020, All Rights Reserved
+# Copyright The IETF Trust 2009-2023, All Rights Reserved
 # -*- coding: utf-8 -*-
 import datetime
 import io
@@ -6106,21 +6106,21 @@ def test_upload_minutes_agenda(self):
     
             test_file = BytesIO(b'this is some text for a test')
             test_file.name = "not_really.json"
-            r = self.client.post(url,dict(file=test_file))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file))
             self.assertEqual(r.status_code, 200)
             q = PyQuery(r.content)
             self.assertTrue(q('form .is-invalid'))
     
             test_file = BytesIO(b'this is some text for a test'*1510000)
             test_file.name = "not_really.pdf"
-            r = self.client.post(url,dict(file=test_file))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file))
             self.assertEqual(r.status_code, 200)
             q = PyQuery(r.content)
             self.assertTrue(q('form .is-invalid'))
     
             test_file = BytesIO(b'<html><frameset><frame src="foo.html"></frame><frame src="bar.html"></frame></frameset></html>')
             test_file.name = "not_really.html"
-            r = self.client.post(url,dict(file=test_file))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file))
             self.assertEqual(r.status_code, 200)
             q = PyQuery(r.content)
             self.assertTrue(q('form .is-invalid'))
@@ -6128,7 +6128,7 @@ def test_upload_minutes_agenda(self):
             # Test html sanitization
             test_file = BytesIO(b'<html><head><title>Title</title></head><body><h1>Title</h1><section>Some text</section></body></html>')
             test_file.name = "some.html"
-            r = self.client.post(url,dict(file=test_file))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file))
             self.assertEqual(r.status_code, 302)
             doc = session.sessionpresentation_set.filter(document__type_id=doctype).first().document
             self.assertEqual(doc.rev,'00')
@@ -6140,7 +6140,7 @@ def test_upload_minutes_agenda(self):
             # txt upload
             test_file = BytesIO(b'This is some text for a test, with the word\nvirtual at the beginning of a line.')
             test_file.name = "some.txt"
-            r = self.client.post(url,dict(file=test_file,apply_to_all=False))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file,apply_to_all=False))
             self.assertEqual(r.status_code, 302)
             doc = session.sessionpresentation_set.filter(document__type_id=doctype).first().document
             self.assertEqual(doc.rev,'01')
@@ -6152,7 +6152,7 @@ def test_upload_minutes_agenda(self):
             self.assertIn('Revise', str(q("Title")))
             test_file = BytesIO(b'this is some different text for a test')
             test_file.name = "also_some.txt"
-            r = self.client.post(url,dict(file=test_file,apply_to_all=True))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file,apply_to_all=True))
             self.assertEqual(r.status_code, 302)
             doc = Document.objects.get(pk=doc.pk)
             self.assertEqual(doc.rev,'02')
@@ -6161,7 +6161,7 @@ def test_upload_minutes_agenda(self):
             # Test bad encoding
             test_file = BytesIO('<html><h1>Title</h1><section>Some\x93text</section></html>'.encode('latin1'))
             test_file.name = "some.html"
-            r = self.client.post(url,dict(file=test_file))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file))
             self.assertContains(r, 'Could not identify the file encoding')
             doc = Document.objects.get(pk=doc.pk)
             self.assertEqual(doc.rev,'02')
@@ -6191,7 +6191,7 @@ def test_upload_minutes_agenda_unscheduled(self):
 
             test_file = BytesIO(b'this is some text for a test')
             test_file.name = "not_really.txt"
-            r = self.client.post(url,dict(file=test_file,apply_to_all=False))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file,apply_to_all=False))
             self.assertEqual(r.status_code, 410)
 
     @override_settings(MEETING_MATERIALS_SERVE_LOCALLY=True)
@@ -6211,7 +6211,7 @@ def test_upload_minutes_agenda_interim(self):
             self.assertFalse(session.sessionpresentation_set.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(file=test_file))
+            r = self.client.post(url,dict(submission_method="upload",file=test_file))
             self.assertEqual(r.status_code, 302)
             doc = session.sessionpresentation_set.filter(document__type_id=doctype).first().document
             self.assertEqual(doc.rev,'00')
@@ -6223,6 +6223,46 @@ def test_upload_minutes_agenda_interim(self):
             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})
+        redirect_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':session.meeting.number,'acronym':session.group.acronym})
+        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.sessionpresentation_set.exists())
+
+        test_text = 'Enter agenda from scratch'
+        r = self.client.post(url,dict(submission_method="enter",content=test_text))
+        self.assertRedirects(r, redirect_url)
+        doc = session.sessionpresentation_set.filter(document__type_id='agenda').first().document
+        self.assertEqual(doc.rev,'00')
+
+        r = self.client.get(url)
+        self.assertEqual(r.status_code, 200)
+        q = PyQuery(r.content)
+        self.assertIn('Revise', str(q("Title")))
+
+        test_file = BytesIO(b'Upload after enter')
+        test_file.name = "some.txt"
+        r = self.client.post(url,dict(submission_method="upload",file=test_file))
+        self.assertRedirects(r, redirect_url)
+        doc = Document.objects.get(pk=doc.pk)
+        self.assertEqual(doc.rev,'01')
+
+        r = self.client.get(url)
+        self.assertEqual(r.status_code, 200)
+        q = PyQuery(r.content)
+        self.assertIn('Revise', str(q("Title")))
+
+        test_text = 'Enter after upload'
+        r = self.client.post(url,dict(submission_method="enter",content=test_text))
+        self.assertRedirects(r, redirect_url)
+        doc = Document.objects.get(pk=doc.pk)
+        self.assertEqual(doc.rev,'02')
+
     def test_upload_slides(self):
 
         session1 = SessionFactory(meeting__type_id='ietf')