From 0b445a9f0985ed6a96569391b39cc1ef4a31a5d0 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 5 Aug 2024 10:47:32 -0300 Subject: [PATCH 01/15] docs: fix email in README.md (#7784) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 133d08f5e..ee9865ba2 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Many developers are using [VS Code](https://code.visualstudio.com/) and taking a If VS Code is not available to you, in your clone, type `cd docker; ./run` -Once the containers are started, run the tests to make sure your checkout is a good place to start from (all tests should pass - if any fail, ask for help at tools-develop@). Inside the app container's shell type: +Once the containers are started, run the tests to make sure your checkout is a good place to start from (all tests should pass - if any fail, ask for help at tools-help@). Inside the app container's shell type: ```sh ietf/manage.py test --settings=settings_test ``` From b13a606a247c8603e7494822c58ff5028ccce5f5 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 5 Aug 2024 11:00:15 -0300 Subject: [PATCH 02/15] feat: recognize HTTPS via proxy (#7765) * feat: set SECURE_PROXY_SSL_HEADER * chore: update comment --- k8s/settings_local.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/k8s/settings_local.py b/k8s/settings_local.py index 6f0956d06..33ac4f1e3 100644 --- a/k8s/settings_local.py +++ b/k8s/settings_local.py @@ -17,6 +17,13 @@ def _multiline_to_list(s): # Default to "development". Production _must_ set DATATRACKER_SERVER_MODE="production" in the env! SERVER_MODE = os.environ.get("DATATRACKER_SERVER_MODE", "development") +# Use X-Forwarded-Proto to determine request.is_secure(). This relies on CloudFlare overwriting the +# value of the header if an incoming request sets it, which it does: +# https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#x-forwarded-proto +# See also, especially the warnings: +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + # Secrets _SECRET_KEY = os.environ.get("DATATRACKER_DJANGO_SECRET_KEY", None) if _SECRET_KEY is not None: From 8a5826a9414ddc53cb721b069e19bf96e2c2c71a Mon Sep 17 00:00:00 2001 From: Rich Salz Date: Mon, 5 Aug 2024 10:48:48 -0400 Subject: [PATCH 03/15] fix: redundant word in banner for Legacy stream documents (#7207) * fix: Remove redundant "stream stream" output fix: Change "Legacy stream" to "Legacy" chore: Add "stream" to stream.desc as needed Fixes: #6902 * chore: Remove unused stream_desc parameter The stream_desc key isn't used in template/doc/docuemnt_draft.html to don't pass it in nor compute it Fixes: #6902 * fix: migrate the legacy StreamName --------- Co-authored-by: Robert Sparks --- ietf/doc/views_doc.py | 8 +------ ietf/name/fixtures/names.json | 2 +- .../0014_change_legacy_stream_desc.py | 21 +++++++++++++++++++ ietf/secr/templates/telechat/doc.html | 2 +- .../doc/mail/last_call_announcement.txt | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 ietf/name/migrations/0014_change_legacy_stream_desc.py diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index dfef40e55..21c5eb235 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -607,12 +607,7 @@ def document_main(request, name, rev=None, document_html=False): additional_urls = doc.documenturl_set.exclude(tag_id='auth48') # Stream description and name passing test - if doc.stream != None: - stream_desc = doc.stream.desc - stream = "draft-stream-" + doc.stream.slug - else: - stream_desc = "(None)" - stream = "(None)" + stream = ("draft-stream-" + doc.stream.slug) if doc.stream != None else "(None)" html = None js = None @@ -651,7 +646,6 @@ def document_main(request, name, rev=None, document_html=False): revisions=simple_diff_revisions if document_html else revisions, snapshot=snapshot, stream=stream, - stream_desc=stream_desc, latest_revision=latest_revision, latest_rev=latest_rev, can_edit=can_edit, diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 3eb2c38d6..59b367deb 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -14058,7 +14058,7 @@ }, { "fields": { - "desc": "Legacy stream", + "desc": "Legacy", "name": "Legacy", "order": 6, "used": true diff --git a/ietf/name/migrations/0014_change_legacy_stream_desc.py b/ietf/name/migrations/0014_change_legacy_stream_desc.py new file mode 100644 index 000000000..8297e8627 --- /dev/null +++ b/ietf/name/migrations/0014_change_legacy_stream_desc.py @@ -0,0 +1,21 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +from django.db import migrations + +def forward(apps, schema_editor): + StreamName = apps.get_model("name", "StreamName") + StreamName.objects.filter(pk="legacy").update(desc="Legacy") + +def reverse(apps, schema_editor): + StreamName = apps.get_model("name", "StreamName") + StreamName.objects.filter(pk="legacy").update(desc="Legacy stream") + +class Migration(migrations.Migration): + + dependencies = [ + ("name", "0013_narrativeminutes"), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/secr/templates/telechat/doc.html b/ietf/secr/templates/telechat/doc.html index 7891c1b1e..6727e157f 100644 --- a/ietf/secr/templates/telechat/doc.html +++ b/ietf/secr/templates/telechat/doc.html @@ -86,7 +86,7 @@

Downward References

{% for ref in downrefs %}

Add {{ref.target.name}} - ({{ref.target.std_level}} - {{ref.target.stream.desc}}) + ({{ref.target.std_level}} - {{ref.target.stream.desc}} stream) to downref registry.
{% if not ref.target.std_level %} +++ Warning: The standards level has not been set yet!!!
diff --git a/ietf/templates/doc/mail/last_call_announcement.txt b/ietf/templates/doc/mail/last_call_announcement.txt index 8f15a8e2a..5cf2e9c45 100644 --- a/ietf/templates/doc/mail/last_call_announcement.txt +++ b/ietf/templates/doc/mail/last_call_announcement.txt @@ -33,7 +33,7 @@ No IPR declarations have been submitted directly on this I-D. {% if downrefs %} The document contains these normative downward references. See RFC 3967 for additional information: -{% for ref in downrefs %} {{ref.target.name}}: {{ref.target.title}} ({{ref.target.std_level}} - {{ref.target.stream.desc}}) +{% for ref in downrefs %} {{ref.target.name}}: {{ref.target.title}} ({{ref.target.std_level}} - {{ref.target.stream.desc}} stream) {% endfor %}{%endif%} {% endautoescape %} From 16ac73d4b748de2ae1162452753a84bc3d6a0369 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 5 Aug 2024 12:18:01 -0300 Subject: [PATCH 04/15] fix: use BOF states in concluded_groups() (#7771) * fix: use BOF states in concluded_groups() * fix: handle events for older BOFs These could be cleaned up in the database, but I think this change does the right thing for the existing data. * style: Black --- ietf/group/views.py | 79 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/ietf/group/views.py b/ietf/group/views.py index f909a31b6..e3fd7e80d 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -334,35 +334,86 @@ def chartering_groups(request): dict(charter_states=charter_states, group_types=group_types)) + def concluded_groups(request): sections = OrderedDict() - sections['WGs'] = Group.objects.filter(type='wg', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") - sections['RGs'] = Group.objects.filter(type='rg', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") - sections['BOFs'] = Group.objects.filter(type='wg', state="bof-conc").select_related("state", "charter").order_by("parent__name","acronym") - sections['AGs'] = Group.objects.filter(type='ag', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") - sections['RAGs'] = Group.objects.filter(type='rag', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") - sections['Directorates'] = Group.objects.filter(type='dir', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") - sections['Review teams'] = Group.objects.filter(type='review', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") - sections['Teams'] = Group.objects.filter(type='team', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") - sections['Programs'] = Group.objects.filter(type='program', state="conclude").select_related("state", "charter").order_by("parent__name","acronym") + sections["WGs"] = ( + Group.objects.filter(type="wg", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["RGs"] = ( + Group.objects.filter(type="rg", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["BOFs"] = ( + Group.objects.filter(type="wg", state="bof-conc") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["AGs"] = ( + Group.objects.filter(type="ag", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["RAGs"] = ( + Group.objects.filter(type="rag", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["Directorates"] = ( + Group.objects.filter(type="dir", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["Review teams"] = ( + Group.objects.filter(type="review", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["Teams"] = ( + Group.objects.filter(type="team", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) + sections["Programs"] = ( + Group.objects.filter(type="program", state="conclude") + .select_related("state", "charter") + .order_by("parent__name", "acronym") + ) for name, groups in sections.items(): - # add start/conclusion date d = dict((g.pk, g) for g in groups) for g in groups: g.start_date = g.conclude_date = None - for e in ChangeStateGroupEvent.objects.filter(group__in=groups, state="active").order_by("-time"): + # Some older BOFs were created in the "active" state, so consider both "active" and "bof" + # ChangeStateGroupEvents when finding the start date. A group with _both_ "active" and "bof" + # events should not be in the "bof-conc" state so this shouldn't cause a problem (if it does, + # we'll need to clean up the data) + for e in ChangeStateGroupEvent.objects.filter( + group__in=groups, + state__in=["active", "bof"] if name == "BOFs" else ["active"], + ).order_by("-time"): d[e.group_id].start_date = e.time - for e in ChangeStateGroupEvent.objects.filter(group__in=groups, state="conclude").order_by("time"): + # Similarly, some older BOFs were concluded into the "conclude" state and the event was never + # fixed, so consider both "conclude" and "bof-conc" ChangeStateGroupEvents when finding the + # concluded date. A group with _both_ "conclude" and "bof-conc" events should not be in the + # "bof-conc" state so this shouldn't cause a problem (if it does, we'll need to clean up the + # data) + for e in ChangeStateGroupEvent.objects.filter( + group__in=groups, + state__in=["bof-conc", "conclude"] if name == "BOFs" else ["conclude"], + ).order_by("time"): d[e.group_id].conclude_date = e.time - return render(request, 'group/concluded_groups.html', - dict(sections=sections)) + return render(request, "group/concluded_groups.html", dict(sections=sections)) + def prepare_group_documents(request, group, clist): found_docs, meta = prepare_document_table(request, docs_tracked_by_community_list(clist), request.GET, max_results=500) From 63d13074d1c6496223b85d1bd471db5211004d16 Mon Sep 17 00:00:00 2001 From: Russ Housley Date: Mon, 5 Aug 2024 12:03:17 -0400 Subject: [PATCH 05/15] fix: use area-acronym-at-the-time in proceedings (#7723) When generating IETF meeting proceedings, use the Area Acronym for each WG at the time of the meeting. Fixes #7706. --- ietf/meeting/views.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 253f2852f..3e483b193 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -4131,6 +4131,13 @@ def organize_proceedings_sessions(sessions): def proceedings(request, num=None): + def area_and_group_acronyms_from_session(s): + area = s.group_parent_at_the_time() + if area == None: + area = s.group.parent + group = s.group_at_the_time() + return (area.acronym, group.acronym) + meeting = get_meeting(num) # Early proceedings were hosted on www.ietf.org rather than the datatracker @@ -4181,12 +4188,11 @@ def proceedings(request, num=None): .exclude(current_status='notmeet') ) - ietf = sessions.filter(group__parent__type__slug = 'area').exclude(group__acronym='edu').order_by('group__parent__acronym', 'group__acronym') + ietf = sessions.filter(group__parent__type__slug = 'area').exclude(group__acronym__in=['edu','iepg','tools']) + ietf = list(ietf) + ietf.sort(key=lambda s: area_and_group_acronyms_from_session(s)) ietf_areas = [] - for area, area_sessions in itertools.groupby( - ietf, - key=lambda s: s.group.parent - ): + for area, area_sessions in itertools.groupby(ietf, key=lambda s: s.group_parent_at_the_time()): meeting_groups, not_meeting_groups = organize_proceedings_sessions(area_sessions) ietf_areas.append((area, meeting_groups, not_meeting_groups)) From 9ef7bff77c1208ed6c9504002aee3320856e6499 Mon Sep 17 00:00:00 2001 From: Jim Fenton Date: Tue, 6 Aug 2024 08:03:37 -0700 Subject: [PATCH 06/15] feat: Unify slide upload and proposal (#7787) * attempt at optional approval * Update of meeting slides propose/upload * Fix tests and residual coding bugs * Remove gratuitous blank lines --- ietf/meeting/forms.py | 7 +- ietf/meeting/tests_views.py | 45 ++++-- ietf/meeting/urls.py | 1 - ietf/meeting/views.py | 130 +++++++----------- .../meeting/propose_session_slides.html | 27 ---- .../meeting/session_details_panel.html | 2 +- ietf/templates/meeting/slides_approved.txt | 2 +- .../meeting/upload_session_slides.html | 12 +- 8 files changed, 95 insertions(+), 131 deletions(-) delete mode 100644 ietf/templates/meeting/propose_session_slides.html diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py index b31ffb6cd..3b66d2cd2 100644 --- a/ietf/meeting/forms.py +++ b/ietf/meeting/forms.py @@ -489,9 +489,12 @@ class UploadAgendaForm(ApplyToAllFileUploadForm): class UploadSlidesForm(ApplyToAllFileUploadForm): doc_type = 'slides' title = forms.CharField(max_length=255) + approved = forms.BooleanField(label='Auto-approve', initial=True, required=False) - def __init__(self, session, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, session, show_apply_to_all_checkbox, can_manage, *args, **kwargs): + super().__init__(show_apply_to_all_checkbox, *args, **kwargs) + if not can_manage: + self.fields.pop('approved') self.session = session def clean_title(self): diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index db62fe620..da82afb32 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -6454,7 +6454,7 @@ class MaterialsTests(TestCase): self.assertFalse(session1.presentations.filter(document__type_id='slides')) test_file = BytesIO(b'this is not really a slide') test_file.name = 'not_really.txt' - r = self.client.post(url,dict(file=test_file,title='a test slide file',apply_to_all=True)) + r = self.client.post(url,dict(file=test_file,title='a test slide file',apply_to_all=True,approved=True)) self.assertEqual(r.status_code, 302) self.assertEqual(session1.presentations.count(),1) self.assertEqual(session2.presentations.count(),1) @@ -6477,7 +6477,7 @@ class MaterialsTests(TestCase): url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id}) test_file = BytesIO(b'some other thing still not slidelike') test_file.name = 'also_not_really.txt' - r = self.client.post(url,dict(file=test_file,title='a different slide file',apply_to_all=False)) + r = self.client.post(url,dict(file=test_file,title='a different slide file',apply_to_all=False,approved=True)) self.assertEqual(r.status_code, 302) self.assertEqual(session1.presentations.count(),1) self.assertEqual(session2.presentations.count(),2) @@ -6501,7 +6501,7 @@ class MaterialsTests(TestCase): self.assertIn('Revise', str(q("title"))) test_file = BytesIO(b'new content for the second slide deck') test_file.name = 'doesnotmatter.txt' - r = self.client.post(url,dict(file=test_file,title='rename the presentation',apply_to_all=False)) + r = self.client.post(url,dict(file=test_file,title='rename the presentation',apply_to_all=False, approved=True)) self.assertEqual(r.status_code, 302) self.assertEqual(session1.presentations.count(),1) self.assertEqual(session2.presentations.count(),2) @@ -6597,7 +6597,7 @@ class MaterialsTests(TestCase): newperson = PersonFactory() session_overview_url = urlreverse('ietf.meeting.views.session_details',kwargs={'num':session.meeting.number,'acronym':session.group.acronym}) - propose_url = urlreverse('ietf.meeting.views.propose_session_slides', kwargs={'session_id':session.pk, 'num': session.meeting.number}) + upload_url = urlreverse('ietf.meeting.views.upload_session_slides', kwargs={'session_id':session.pk, 'num': session.meeting.number}) r = self.client.get(session_overview_url) self.assertEqual(r.status_code,200) @@ -6612,13 +6612,13 @@ class MaterialsTests(TestCase): self.assertTrue(q('.proposeslides')) self.client.logout() - login_testing_unauthorized(self,newperson.user.username,propose_url) - r = self.client.get(propose_url) + login_testing_unauthorized(self,newperson.user.username,upload_url) + r = self.client.get(upload_url) self.assertEqual(r.status_code,200) test_file = BytesIO(b'this is not really a slide') test_file.name = 'not_really.txt' empty_outbox() - r = self.client.post(propose_url,dict(file=test_file,title='a test slide file',apply_to_all=True)) + r = self.client.post(upload_url,dict(file=test_file,title='a test slide file',apply_to_all=True,approved=False)) self.assertEqual(r.status_code, 302) session = Session.objects.get(pk=session.pk) self.assertEqual(session.slidesubmission_set.count(),1) @@ -6639,6 +6639,25 @@ class MaterialsTests(TestCase): self.assertEqual(len(q('.proposedslidelist p')), 2) self.client.logout() + login_testing_unauthorized(self,chair.user.username,upload_url) + r = self.client.get(upload_url) + self.assertEqual(r.status_code,200) + test_file = BytesIO(b'this is not really a slide either') + test_file.name = 'again_not_really.txt' + empty_outbox() + r = self.client.post(upload_url,dict(file=test_file,title='a selfapproved test slide file',apply_to_all=True,approved=True)) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox),0) + self.assertEqual(session.slidesubmission_set.count(),2) + self.client.logout() + + self.client.login(username=chair.user.username, password=chair.user.username+"+password") + r = self.client.get(session_overview_url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual(len(q('.uploadslidelist p')), 0) + self.client.logout() + def test_disapprove_proposed_slides(self): submission = SlideSubmissionFactory() submission.session.meeting.importantdate_set.create(name_id='revsub',date=date_today() + datetime.timedelta(days=20)) @@ -6759,12 +6778,12 @@ class MaterialsTests(TestCase): session.meeting.importantdate_set.create(name_id='revsub',date=date_today()+datetime.timedelta(days=20)) newperson = PersonFactory() - propose_url = urlreverse('ietf.meeting.views.propose_session_slides', kwargs={'session_id':session.pk, 'num': session.meeting.number}) + upload_url = urlreverse('ietf.meeting.views.upload_session_slides', kwargs={'session_id':session.pk, 'num': session.meeting.number}) - login_testing_unauthorized(self,newperson.user.username,propose_url) + login_testing_unauthorized(self,newperson.user.username,upload_url) test_file = BytesIO(b'this is not really a slide') test_file.name = 'not_really.txt' - r = self.client.post(propose_url,dict(file=test_file,title='a test slide file',apply_to_all=True)) + r = self.client.post(upload_url,dict(file=test_file,title='a test slide file',apply_to_all=True,approved=False)) self.assertEqual(r.status_code, 302) self.client.logout() @@ -6787,15 +6806,15 @@ class MaterialsTests(TestCase): self.assertEqual(session.presentations.first().document.rev,'00') - login_testing_unauthorized(self,newperson.user.username,propose_url) + login_testing_unauthorized(self,newperson.user.username,upload_url) test_file = BytesIO(b'this is not really a slide, but it is another version of it') test_file.name = 'not_really.txt' - r = self.client.post(propose_url,dict(file=test_file,title='a test slide file',apply_to_all=True)) + r = self.client.post(upload_url,dict(file=test_file,title='a test slide file',apply_to_all=True)) self.assertEqual(r.status_code, 302) test_file = BytesIO(b'this is not really a slide, but it is third version of it') test_file.name = 'not_really.txt' - r = self.client.post(propose_url,dict(file=test_file,title='a test slide file',apply_to_all=True)) + r = self.client.post(upload_url,dict(file=test_file,title='a test slide file',apply_to_all=True)) self.assertEqual(r.status_code, 302) self.client.logout() diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 26d3d93b2..f2e65578e 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -22,7 +22,6 @@ safe_for_all_meeting_types = [ url(r'^session/(?P\d+)/narrativeminutes$', views.upload_session_narrativeminutes), url(r'^session/(?P\d+)/agenda$', views.upload_session_agenda), url(r'^session/(?P\d+)/import/minutes$', views.import_session_minutes), - url(r'^session/(?P\d+)/propose_slides$', views.propose_session_slides), url(r'^session/(?P\d+)/slides(?:/%(name)s)?$' % settings.URL_REGEXPS, views.upload_session_slides), url(r'^session/(?P\d+)/add_to_session$', views.ajax_add_slides_to_session), url(r'^session/(?P\d+)/remove_from_session$', views.ajax_remove_slides_from_session), diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 3e483b193..c3494a13b 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1702,7 +1702,7 @@ def api_get_session_materials(request, session_id=None): minutes = session.minutes() slides_actions = [] - if can_manage_session_materials(request.user, session.group, session): + if can_manage_session_materials(request.user, session.group, session) or not session.is_material_submission_cutoff(): slides_actions.append( { "label": "Upload slides", @@ -1712,16 +1712,6 @@ def api_get_session_materials(request, session_id=None): ), } ) - elif not session.is_material_submission_cutoff(): - slides_actions.append( - { - "label": "Propose slides", - "url": reverse( - "ietf.meeting.views.propose_session_slides", - kwargs={"num": session.meeting.number, "session_id": session.pk}, - ), - } - ) else: pass # no action available if it's past cutoff @@ -2920,6 +2910,7 @@ def upload_session_agenda(request, session_id, num): }) +@login_required def upload_session_slides(request, session_id, num, name=None): """Upload new or replacement slides for a session @@ -2927,10 +2918,7 @@ def upload_session_slides(request, session_id, num, name=None): """ # 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 not session.can_manage_materials(request.user): - permission_denied( - request, "You don't have permission to upload slides for this session." - ) + can_manage = session.can_manage_materials(request.user) if session.is_material_submission_cutoff() and not has_role( request.user, "Secretariat" ): @@ -2955,7 +2943,7 @@ def upload_session_slides(request, session_id, num, name=None): if request.method == "POST": form = UploadSlidesForm( - session, show_apply_to_all_checkbox, request.POST, request.FILES + session, show_apply_to_all_checkbox, can_manage, request.POST, request.FILES ) if form.is_valid(): file = request.FILES["file"] @@ -2963,6 +2951,46 @@ def upload_session_slides(request, session_id, num, name=None): apply_to_all = session.type_id == "regular" if show_apply_to_all_checkbox: apply_to_all = form.cleaned_data["apply_to_all"] + if can_manage: + approved = form.cleaned_data["approved"] + else: + approved = False + + # Propose slides if not auto-approved + if not approved: + title = form.cleaned_data['title'] + submission = SlideSubmission.objects.create(session = session, title = title, filename = '', apply_to_all = apply_to_all, submitter=request.user.person) + + if session.meeting.type_id=='ietf': + name = 'slides-%s-%s' % (session.meeting.number, + session.group.acronym) + if not apply_to_all: + name += '-%s' % (session.docname_token(),) + else: + name = 'slides-%s-%s' % (session.meeting.number, session.docname_token()) + name = name + '-' + slugify(title).replace('_', '-')[:128] + filename = '%s-ss%d%s'% (name, submission.id, ext) + destination = io.open(os.path.join(settings.SLIDE_STAGING_PATH, filename),'wb+') + for chunk in file.chunks(): + destination.write(chunk) + destination.close() + + submission.filename = filename + submission.save() + + (to, cc) = gather_address_lists('slides_proposed', group=session.group, proposer=request.user.person).as_strings() + msg_txt = render_to_string("meeting/slides_proposed.txt", { + "to": to, + "cc": cc, + "submission": submission, + "settings": settings, + }) + msg = infer_message(msg_txt) + msg.by = request.user.person + msg.save() + send_mail_message(request, msg) + messages.success(request, 'Successfully submitted proposed slides.') + return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym) # Handle creation / update of the Document (but do not save yet) if doc is not None: @@ -3076,7 +3104,7 @@ def upload_session_slides(request, session_id, num, name=None): initial = {} if doc is not None: initial = {"title": doc.title} - form = UploadSlidesForm(session, show_apply_to_all_checkbox, initial=initial) + form = UploadSlidesForm(session, show_apply_to_all_checkbox, can_manage, initial=initial) return render( request, @@ -3085,77 +3113,12 @@ def upload_session_slides(request, session_id, num, name=None): "session": session, "session_number": session_number, "slides_sp": session.presentations.filter(document=doc).first() if doc else None, + "manage": session.can_manage_materials(request.user), "form": form, }, ) -@login_required -def propose_session_slides(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") - - 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) - - - if request.method == 'POST': - form = UploadSlidesForm(session, 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'] - title = form.cleaned_data['title'] - - submission = SlideSubmission.objects.create(session = session, title = title, filename = '', apply_to_all = apply_to_all, submitter=request.user.person) - - if session.meeting.type_id=='ietf': - name = 'slides-%s-%s' % (session.meeting.number, - session.group.acronym) - if not apply_to_all: - name += '-%s' % (session.docname_token(),) - else: - name = 'slides-%s-%s' % (session.meeting.number, session.docname_token()) - name = name + '-' + slugify(title).replace('_', '-')[:128] - filename = '%s-ss%d%s'% (name, submission.id, ext) - destination = io.open(os.path.join(settings.SLIDE_STAGING_PATH, filename),'wb+') - for chunk in file.chunks(): - destination.write(chunk) - destination.close() - - submission.filename = filename - submission.save() - - (to, cc) = gather_address_lists('slides_proposed', group=session.group, proposer=request.user.person).as_strings() - msg_txt = render_to_string("meeting/slides_proposed.txt", { - "to": to, - "cc": cc, - "submission": submission, - "settings": settings, - }) - msg = infer_message(msg_txt) - msg.by = request.user.person - msg.save() - send_mail_message(request, msg) - messages.success(request, 'Successfully submitted proposed slides.') - return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym) - else: - initial = {} - form = UploadSlidesForm(session, show_apply_to_all_checkbox, initial=initial) - - return render(request, "meeting/propose_session_slides.html", - {'session': session, - 'session_number': session_number, - 'form': form, - }) - - def remove_sessionpresentation(request, session_id, num, name): sp = get_object_or_404( SessionPresentation, session_id=session_id, document__name=name @@ -5072,6 +5035,7 @@ def approve_proposed_slides(request, slidesubmission_id, num): "cc": cc, "submission": submission, "settings": settings, + "approver": request.user.person }) send_mail_text(request, to, None, subject, body, cc=cc) return redirect('ietf.meeting.views.session_details',num=num,acronym=acronym) diff --git a/ietf/templates/meeting/propose_session_slides.html b/ietf/templates/meeting/propose_session_slides.html deleted file mode 100644 index e5a0b451e..000000000 --- a/ietf/templates/meeting/propose_session_slides.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin static django_bootstrap5 tz %} -{% block title %}Propose Slides for {{ session.meeting }} : {{ session.group.acronym }}{% endblock %} -{% block content %} - {% origin %} -

- Propose Slides for {{ session.meeting }} -
- {{ session.group.acronym }} - {% if session.name %}: {{ session.name }}{% endif %} - -

- {% if session_number %} -

- Session {{ session_number }} : {{ session.official_timeslotassignment.timeslot.time|timezone:session.meeting.time_zone|date:"D M-d-Y Hi" }} -

- {% endif %} -

- This form will allow you to propose a slide deck to the session chairs. After you upload your proposal, mail will be sent to the session chairs asking for their approval. -

-
- {% csrf_token %} - {% bootstrap_form form %} - -
-{% endblock %} \ No newline at end of file diff --git a/ietf/templates/meeting/session_details_panel.html b/ietf/templates/meeting/session_details_panel.html index d053ba1c1..1dcbded91 100644 --- a/ietf/templates/meeting/session_details_panel.html +++ b/ietf/templates/meeting/session_details_panel.html @@ -187,7 +187,7 @@ {% elif request.user.is_authenticated and not session.is_material_submission_cutoff %} + href="{% url 'ietf.meeting.views.upload_session_slides' session_id=session.pk num=session.meeting.number %}"> Propose slides {% endif %} diff --git a/ietf/templates/meeting/slides_approved.txt b/ietf/templates/meeting/slides_approved.txt index db288ad85..61ffafcd1 100644 --- a/ietf/templates/meeting/slides_approved.txt +++ b/ietf/templates/meeting/slides_approved.txt @@ -1,4 +1,4 @@ -{% load ietf_filters %}{% autoescape off %}Your proposed slides have been approved for {{ submission.session.meeting }} : {{ submission.session.group.acronym }}{% if submission.session.name %} : {{submission.session.name}}{% endif %} +{% load ietf_filters %}{% autoescape off %}Your proposed slides have been approved for {{ submission.session.meeting }} : {{ submission.session.group.acronym }}{% if submission.session.name %} : {{submission.session.name}}{% endif %} by {{approver}} Title: {{submission.title}} diff --git a/ietf/templates/meeting/upload_session_slides.html b/ietf/templates/meeting/upload_session_slides.html index 8e3e064df..059ffae16 100644 --- a/ietf/templates/meeting/upload_session_slides.html +++ b/ietf/templates/meeting/upload_session_slides.html @@ -17,15 +17,21 @@ {% else %} Upload new {% endif %} - slides for {{ session.meeting }} -
+ slides for {{ session.meeting }}
{{ session.group.acronym }} {% if session.name %}: {{ session.name }}{% endif %} {% if session_number %} -

Session {{ session_number }} : {{ session.official_timeslotassignment.timeslot.time|timezone:session.meeting.time_zone|date:"D M-d-Y Hi" }}

+

+ Session {{ session_number }} : {{ session.official_timeslotassignment.timeslot.time|timezone:session.meeting.time_zone|date:"D M-d-Y Hi" }} +

+ {% endif %} + {% if not manage %} +

+ This form will allow you to propose a slide deck to the session chairs. After you upload your proposal, mail will be sent to the session chairs asking for their approval. +

{% endif %} {% if slides_sp %}

{{ slides_sp.document.name }}

{% endif %}
From b90820eb7a74ecaa417df120229ca3f798602e69 Mon Sep 17 00:00:00 2001 From: Sangho Na Date: Thu, 8 Aug 2024 02:15:01 +1200 Subject: [PATCH 07/15] fix: Hide last modified field in agenda when unavailable (#7722) * test: Update tests to check for Updated field in agenda.txt * fix: Hide Updated in agenda.txt if too old * test: Remove confusing tests on CSV agenda * refactor: Make updated() return None when no valid timestamp found * refactor: Remove walrus operator --- client/agenda/Agenda.vue | 2 +- ietf/meeting/models.py | 13 ++++---- ietf/meeting/tests_views.py | 50 ++++++++++++++++++++++++++++++- ietf/meeting/utils.py | 3 +- ietf/templates/meeting/agenda.txt | 2 ++ 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/client/agenda/Agenda.vue b/client/agenda/Agenda.vue index 34a3751f2..40dae008a 100644 --- a/client/agenda/Agenda.vue +++ b/client/agenda/Agenda.vue @@ -323,7 +323,7 @@ const meetingUpdated = computed(() => { if (!agendaStore.meeting.updated) { return false } const updatedDatetime = DateTime.fromISO(agendaStore.meeting.updated).setZone(agendaStore.timezone) - if (!updatedDatetime.isValid || updatedDatetime < DateTime.fromISO('1980-01-01')) { + if (!updatedDatetime.isValid) { return false } diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index dd6e2db6c..fa1ad9d67 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -369,13 +369,14 @@ class Meeting(models.Model): def updated(self): # should be Meeting.modified, but we don't have that - min_time = pytz.utc.localize(datetime.datetime(1970, 1, 1, 0, 0, 0)) - timeslots_updated = self.timeslot_set.aggregate(Max('modified'))["modified__max"] or min_time - sessions_updated = self.session_set.aggregate(Max('modified'))["modified__max"] or min_time - assignments_updated = min_time + timeslots_updated = self.timeslot_set.aggregate(Max('modified'))["modified__max"] + sessions_updated = self.session_set.aggregate(Max('modified'))["modified__max"] + assignments_updated = None if self.schedule: - assignments_updated = SchedTimeSessAssignment.objects.filter(schedule__in=[self.schedule, self.schedule.base if self.schedule else None]).aggregate(Max('modified'))["modified__max"] or min_time - return max(timeslots_updated, sessions_updated, assignments_updated) + assignments_updated = SchedTimeSessAssignment.objects.filter(schedule__in=[self.schedule, self.schedule.base if self.schedule else None]).aggregate(Max('modified'))["modified__max"] + dts = [timeslots_updated, sessions_updated, assignments_updated] + valid_only = [dt for dt in dts if dt is not None] + return max(valid_only) if valid_only else None @memoize def previous_meeting(self): diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index da82afb32..60eef96b2 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -294,6 +294,8 @@ class MeetingTests(BaseMeetingTestCase): (slot.time + slot.duration).astimezone(meeting.tz()).strftime("%H%M"), )) self.assertContains(r, f"shown in the {meeting.tz()} time zone") + updated = meeting.updated().astimezone(meeting.tz()).strftime("%Y-%m-%d %H:%M:%S %Z") + self.assertContains(r, f"Updated {updated}") # text, UTC r = self.client.get(urlreverse( @@ -309,6 +311,16 @@ class MeetingTests(BaseMeetingTestCase): (slot.time + slot.duration).astimezone(datetime.timezone.utc).strftime("%H%M"), )) self.assertContains(r, "shown in UTC") + updated = meeting.updated().astimezone(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z") + self.assertContains(r, f"Updated {updated}") + + # text, invalid updated (none) + with patch("ietf.meeting.models.Meeting.updated", return_value=None): + r = self.client.get(urlreverse( + "ietf.meeting.views.agenda_plain", + kwargs=dict(num=meeting.number, ext=".txt", utc="-utc"), + )) + self.assertNotContains(r, "Updated ") # future meeting, no agenda r = self.client.get(urlreverse("ietf.meeting.views.agenda_plain", kwargs=dict(num=future_meeting.number, ext=".txt"))) @@ -859,6 +871,24 @@ class MeetingTests(BaseMeetingTestCase): for d in meeting.importantdate_set.all(): self.assertContains(r, d.date.isoformat()) + updated = meeting.updated() + self.assertIsNotNone(updated) + expected_updated = updated.astimezone(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + self.assertContains(r, f"DTSTAMP:{expected_updated}") + dtstamps_count = r.content.decode("utf-8").count(f"DTSTAMP:{expected_updated}") + self.assertEqual(dtstamps_count, meeting.importantdate_set.count()) + + # With default cached_updated, 1970-01-01 + with patch("ietf.meeting.models.Meeting.updated", return_value=None): + r = self.client.get(url) + for d in meeting.importantdate_set.all(): + self.assertContains(r, d.date.isoformat()) + + expected_updated = "19700101T000000Z" + self.assertContains(r, f"DTSTAMP:{expected_updated}") + dtstamps_count = r.content.decode("utf-8").count(f"DTSTAMP:{expected_updated}") + self.assertEqual(dtstamps_count, meeting.importantdate_set.count()) + def test_group_ical(self): meeting = make_meeting_test_data() s1 = Session.objects.filter(meeting=meeting, group__acronym="mars").first() @@ -4952,7 +4982,23 @@ class InterimTests(TestCase): expected_event_count=len(expected_event_summaries)) self.assertNotContains(r, 'Remote instructions:') - def test_upcoming_ical_filter(self): + updated = meeting.updated() + self.assertIsNotNone(updated) + expected_updated = updated.astimezone(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + self.assertContains(r, f"DTSTAMP:{expected_updated}") + + # With default cached_updated, 1970-01-01 + with patch("ietf.meeting.models.Meeting.updated", return_value=None): + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + self.assertEqual(meeting.type_id, "ietf") + + expected_updated = "19700101T000000Z" + self.assertEqual(1, r.content.decode("utf-8").count(f"DTSTAMP:{expected_updated}")) + + @patch("ietf.meeting.utils.preprocess_meeting_important_dates") + def test_upcoming_ical_filter(self, mock_preprocess_meeting_important_dates): # Just a quick check of functionality - details tested by test_js.InterimTests make_meeting_test_data(create_interims=True) url = urlreverse("ietf.meeting.views.upcoming_ical") @@ -4974,6 +5020,8 @@ class InterimTests(TestCase): ], expected_event_count=2) + # Verify preprocess_meeting_important_dates isn't being called + mock_preprocess_meeting_important_dates.assert_not_called() def test_upcoming_json(self): make_meeting_test_data(create_interims=True) diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index a60d3b010..470795811 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -609,7 +609,8 @@ def bulk_create_timeslots(meeting, times, locations, other_props): def preprocess_meeting_important_dates(meetings): for m in meetings: - m.cached_updated = m.updated() + # cached_updated must be present, set it to 1970-01-01 if necessary + m.cached_updated = m.updated() or pytz.utc.localize(datetime.datetime(1970, 1, 1, 0, 0, 0)) m.important_dates = m.importantdate_set.prefetch_related("name") for d in m.important_dates: d.midnight_cutoff = "UTC 23:59" in d.name.name diff --git a/ietf/templates/meeting/agenda.txt b/ietf/templates/meeting/agenda.txt index d00be80d1..7a49dde0c 100644 --- a/ietf/templates/meeting/agenda.txt +++ b/ietf/templates/meeting/agenda.txt @@ -7,7 +7,9 @@ {% filter center:72 %}{{ schedule.meeting.agenda_info_note|striptags|wordwrap:72|safe }}{% endfilter %} {% endif %} {% filter center:72 %}{{ schedule.meeting.date|date:"F j" }}-{% if schedule.meeting.date.month != schedule.meeting.end_date.month %}{{ schedule.meeting.end_date|date:"F " }}{% endif %}{{ schedule.meeting.end_date|date:"j, Y" }}{% endfilter %} +{% if updated %} {% filter center:72 %}Updated {{ updated|date:"Y-m-d H:i:s T" }}{% endfilter %} +{% endif %} {% filter center:72 %}IETF agendas are subject to change, up to and during the meeting.{% endfilter %} {% filter center:72 %}Times are shown in {% if display_timezone.lower == "utc" %}UTC{% else %}the {{ display_timezone }} time zone{% endif %}.{% endfilter %} From 95a7e14ada82fd43eafed7218a28ba0554c81e69 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 7 Aug 2024 11:16:40 -0300 Subject: [PATCH 08/15] feat: dev mode admin + refactor api init (#7628) * feat: style admin site in dev mode * refactor: eliminate base_site.html * fix: remove debug * fix: commit missing __init__.py * refactor: make method static; fix tests * refactor: move api init to AppConfig.ready() Avoids interacting with the app registry before it's ready. --- ietf/admin/__init__.py | 0 ietf/admin/apps.py | 6 +++++ ietf/admin/sites.py | 15 ++++++++++++ ietf/api/__init__.py | 43 ++++++++++++++-------------------- ietf/api/__init__.pyi | 1 + ietf/api/apps.py | 15 ++++++++++++ ietf/api/urls.py | 1 + ietf/settings.py | 2 +- ietf/templates/admin/base.html | 27 +++++++++++++++++++++ ietf/urls.py | 2 -- ietf/utils/tests.py | 3 ++- 11 files changed, 85 insertions(+), 30 deletions(-) create mode 100644 ietf/admin/__init__.py create mode 100644 ietf/admin/apps.py create mode 100644 ietf/admin/sites.py create mode 100644 ietf/api/apps.py create mode 100644 ietf/templates/admin/base.html diff --git a/ietf/admin/__init__.py b/ietf/admin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/admin/apps.py b/ietf/admin/apps.py new file mode 100644 index 000000000..20b762cfe --- /dev/null +++ b/ietf/admin/apps.py @@ -0,0 +1,6 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +from django.contrib.admin import apps as admin_apps + + +class AdminConfig(admin_apps.AdminConfig): + default_site = "ietf.admin.sites.AdminSite" diff --git a/ietf/admin/sites.py b/ietf/admin/sites.py new file mode 100644 index 000000000..69cb62ae2 --- /dev/null +++ b/ietf/admin/sites.py @@ -0,0 +1,15 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +from django.contrib.admin import AdminSite as _AdminSite +from django.conf import settings +from django.utils.safestring import mark_safe + + +class AdminSite(_AdminSite): + site_title = "Datatracker admin" + + @staticmethod + def site_header(): + if settings.SERVER_MODE == "production": + return "Datatracker administration" + else: + return mark_safe('Datatracker administration δ') diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py index 54b4b7424..81f370121 100644 --- a/ietf/api/__init__.py +++ b/ietf/api/__init__.py @@ -7,8 +7,10 @@ import re from urllib.parse import urlencode -from django.conf import settings +from django.apps import apps as django_apps from django.core.exceptions import ObjectDoesNotExist +from django.utils.module_loading import autodiscover_modules + import debug # pyflakes:ignore @@ -19,40 +21,29 @@ from tastypie.bundle import Bundle from tastypie.exceptions import ApiFieldError from tastypie.fields import ApiField + _api_list = [] -for _app in settings.INSTALLED_APPS: - _module_dict = globals() - if '.' in _app: - _root, _name = _app.split('.', 1) - if _root == 'ietf': - if not '.' in _name: - _api = Api(api_name=_name) - _module_dict[_name] = _api - _api_list.append((_name, _api)) + +def populate_api_list(): + for app_config in django_apps.get_app_configs(): + _module_dict = globals() + if '.' in app_config.name: + _root, _name = app_config.name.split('.', 1) + if _root == 'ietf': + if not '.' in _name: + _api = Api(api_name=_name) + _module_dict[_name] = _api + _api_list.append((_name, _api)) def autodiscover(): """ Auto-discover INSTALLED_APPS resources.py modules and fail silently when - not present. This forces an import on them to register any admin bits they + not present. This forces an import on them to register any resources they may want. """ + autodiscover_modules("resources") - from importlib import import_module - from django.conf import settings - from django.utils.module_loading import module_has_submodule - - for app in settings.INSTALLED_APPS: - mod = import_module(app) - # Attempt to import the app's admin module. - try: - import_module('%s.resources' % (app, )) - except: - # Decide whether to bubble up this error. If the app just - # doesn't have an admin module, we can ignore the error - # attempting to import it, otherwise we want it to bubble up. - if module_has_submodule(mod, "resources"): - raise class ModelResource(tastypie.resources.ModelResource): def generate_cache_key(self, *args, **kwargs): diff --git a/ietf/api/__init__.pyi b/ietf/api/__init__.pyi index 63d9bc513..ededea90a 100644 --- a/ietf/api/__init__.pyi +++ b/ietf/api/__init__.pyi @@ -30,4 +30,5 @@ class Serializer(): ... class ToOneField(tastypie.fields.ToOneField): ... class TimedeltaField(tastypie.fields.ApiField): ... +def populate_api_list() -> None: ... def autodiscover() -> None: ... diff --git a/ietf/api/apps.py b/ietf/api/apps.py new file mode 100644 index 000000000..7eca094a6 --- /dev/null +++ b/ietf/api/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig +from . import populate_api_list + + +class ApiConfig(AppConfig): + name = "ietf.api" + + def ready(self): + """Hook to do init after the app registry is fully populated + + Importing models or accessing the app registry is ok here, but do not + interact with the database. See + https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready + """ + populate_api_list() diff --git a/ietf/api/urls.py b/ietf/api/urls.py index fb2184a3f..3c0fb872c 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -11,6 +11,7 @@ from ietf.meeting import views as meeting_views from ietf.submit import views as submit_views from ietf.utils.urls import url + api.autodiscover() urlpatterns = [ diff --git a/ietf/settings.py b/ietf/settings.py index 13b750667..7572b1521 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -436,7 +436,7 @@ STATICFILES_DIRS = ( INSTALLED_APPS = [ # Django apps - 'django.contrib.admin', + 'ietf.admin', # replaces django.contrib.admin 'django.contrib.admindocs', 'django.contrib.auth', 'django.contrib.contenttypes', diff --git a/ietf/templates/admin/base.html b/ietf/templates/admin/base.html new file mode 100644 index 000000000..9ca7377a5 --- /dev/null +++ b/ietf/templates/admin/base.html @@ -0,0 +1,27 @@ +{% extends 'admin/base.html' %} +{% load static %} +{% block extrastyle %}{{ block.super }} + {% if server_mode and server_mode != "production" %} + + {% endif %} +{% endblock %} diff --git a/ietf/urls.py b/ietf/urls.py index 4b29a3aa8..90b161b53 100644 --- a/ietf/urls.py +++ b/ietf/urls.py @@ -20,8 +20,6 @@ from ietf.liaisons.sitemaps import LiaisonMap from ietf.utils.urls import url -admin.autodiscover() - # sometimes, this code gets called more than once, which is an # that seems impossible to work around. try: diff --git a/ietf/utils/tests.py b/ietf/utils/tests.py index 476c257a3..d435583e8 100644 --- a/ietf/utils/tests.py +++ b/ietf/utils/tests.py @@ -36,6 +36,7 @@ from django.urls import reverse as urlreverse import debug # pyflakes:ignore +from ietf.admin.sites import AdminSite from ietf.person.name import name_parts, unidecode_name from ietf.submit.tests import submission_file from ietf.utils.draft import PlaintextDraft, getmeta @@ -325,7 +326,7 @@ class AdminTestCase(TestCase): User.objects.create_superuser('admin', 'admin@example.org', 'admin+password') self.client.login(username='admin', password='admin+password') rtop = self.client.get("/admin/") - self.assertContains(rtop, 'Django administration') + self.assertContains(rtop, AdminSite.site_header()) for name in self.apps: app_name = self.apps[name] self.assertContains(rtop, name) From 1de41965bee819d85b5b76f6a2db5c2e5934a6b6 Mon Sep 17 00:00:00 2001 From: Russ Housley Date: Wed, 7 Aug 2024 10:18:13 -0400 Subject: [PATCH 09/15] fix: add celery tasks for idnits2 and bibxml file generation (#7204) * Add celery tasks for idnits2 and bibxml file generation * Update tests_tasks.py Fix typo: bad_vakue -> bad_value * Update utils.py Don't raise error inbibxml_for_all_drafts * chore: fixup merge * chore: more merge cleanup * chore: one last merge cleanup --------- Co-authored-by: Jennifer Richards --- ietf/doc/tasks.py | 2 + ietf/doc/tests_tasks.py | 131 ++++++++++++++++++++++------------------ ietf/doc/utils.py | 3 +- 3 files changed, 76 insertions(+), 60 deletions(-) diff --git a/ietf/doc/tasks.py b/ietf/doc/tasks.py index 209db035a..f1de459dd 100644 --- a/ietf/doc/tasks.py +++ b/ietf/doc/tasks.py @@ -105,6 +105,8 @@ def generate_draft_bibxml_files_task(days=7, process_all=False): If process_all is False (the default), processes only docs with new revisions in the last specified number of days. """ + if not process_all and days < 1: + raise ValueError("Must call with days >= 1 or process_all=True") ensure_draft_bibxml_path_exists() doc_events = NewRevisionDocEvent.objects.filter( type="new_revision", diff --git a/ietf/doc/tests_tasks.py b/ietf/doc/tests_tasks.py index 51a8556e6..b75f58656 100644 --- a/ietf/doc/tests_tasks.py +++ b/ietf/doc/tests_tasks.py @@ -22,8 +22,6 @@ from .tasks import ( ) class TaskTests(TestCase): - settings_temp_path_overrides = TestCase.settings_temp_path_overrides + ["DERIVED_DIR"] - @mock.patch("ietf.doc.tasks.in_draft_expire_freeze") @mock.patch("ietf.doc.tasks.get_expired_drafts") @mock.patch("ietf.doc.tasks.expirable_drafts") @@ -63,8 +61,8 @@ class TaskTests(TestCase): # test that an exception is raised in_draft_expire_freeze_mock.side_effect = RuntimeError - with self.assertRaises(RuntimeError): ( - expire_ids_task()) + with self.assertRaises(RuntimeError): + expire_ids_task() @mock.patch("ietf.doc.tasks.send_expire_warning_for_draft") @mock.patch("ietf.doc.tasks.get_soon_to_expire_drafts") @@ -98,16 +96,10 @@ class TaskTests(TestCase): self.assertEqual(mock_expire.call_args_list[1], mock.call(docs[1])) self.assertEqual(mock_expire.call_args_list[2], mock.call(docs[2])) - @mock.patch("ietf.doc.tasks.generate_idnits2_rfc_status") - def test_generate_idnits2_rfc_status_task(self, mock_generate): - mock_generate.return_value = "dåtå" - generate_idnits2_rfc_status_task() - self.assertEqual(mock_generate.call_count, 1) - self.assertEqual( - "dåtå".encode("utf8"), - (Path(settings.DERIVED_DIR) / "idnits2-rfc-status").read_bytes(), - ) - + +class Idnits2SupportTests(TestCase): + settings_temp_path_overrides = TestCase.settings_temp_path_overrides + ['DERIVED_DIR'] + @mock.patch("ietf.doc.tasks.generate_idnits2_rfcs_obsoleted") def test_generate_idnits2_rfcs_obsoleted_task(self, mock_generate): mock_generate.return_value = "dåtå" @@ -118,17 +110,28 @@ class TaskTests(TestCase): (Path(settings.DERIVED_DIR) / "idnits2-rfcs-obsoleted").read_bytes(), ) - @mock.patch("ietf.doc.tasks.ensure_draft_bibxml_path_exists") - @mock.patch("ietf.doc.tasks.update_or_create_draft_bibxml_file") - def test_generate_draft_bibxml_files_task(self, mock_create, mock_ensure_path): + @mock.patch("ietf.doc.tasks.generate_idnits2_rfc_status") + def test_generate_idnits2_rfc_status_task(self, mock_generate): + mock_generate.return_value = "dåtå" + generate_idnits2_rfc_status_task() + self.assertEqual(mock_generate.call_count, 1) + self.assertEqual( + "dåtå".encode("utf8"), + (Path(settings.DERIVED_DIR) / "idnits2-rfc-status").read_bytes(), + ) + + +class BIBXMLSupportTests(TestCase): + def setUp(self): + super().setUp() now = timezone.now() - very_old_event = NewRevisionDocEventFactory( + self.very_old_event = NewRevisionDocEventFactory( time=now - datetime.timedelta(days=1000), rev="17" ) - old_event = NewRevisionDocEventFactory( + self.old_event = NewRevisionDocEventFactory( time=now - datetime.timedelta(days=8), rev="03" ) - young_event = NewRevisionDocEventFactory( + self.young_event = NewRevisionDocEventFactory( time=now - datetime.timedelta(days=6), rev="06" ) # a couple that should always be ignored @@ -141,53 +144,25 @@ class TaskTests(TestCase): rev="09", doc__type_id="rfc", ) - # Get rid of the "00" events created by the factories -- they're just noise for this test NewRevisionDocEvent.objects.filter(rev="00").delete() - - # default args - look back 7 days - generate_draft_bibxml_files_task() - self.assertTrue(mock_ensure_path.called) - self.assertCountEqual( - mock_create.call_args_list, [mock.call(young_event.doc, young_event.rev)] - ) - mock_create.reset_mock() - mock_ensure_path.reset_mock() - - # shorter lookback - generate_draft_bibxml_files_task(days=5) - self.assertTrue(mock_ensure_path.called) - self.assertCountEqual(mock_create.call_args_list, []) - mock_create.reset_mock() - mock_ensure_path.reset_mock() - - # longer lookback - generate_draft_bibxml_files_task(days=9) - self.assertTrue(mock_ensure_path.called) - self.assertCountEqual( - mock_create.call_args_list, - [ - mock.call(young_event.doc, young_event.rev), - mock.call(old_event.doc, old_event.rev), - ], - ) - mock_create.reset_mock() - mock_ensure_path.reset_mock() - - # everything + + @mock.patch("ietf.doc.tasks.ensure_draft_bibxml_path_exists") + @mock.patch("ietf.doc.tasks.update_or_create_draft_bibxml_file") + def test_generate_bibxml_files_for_all_drafts_task(self, mock_create, mock_ensure_path): generate_draft_bibxml_files_task(process_all=True) self.assertTrue(mock_ensure_path.called) self.assertCountEqual( mock_create.call_args_list, [ - mock.call(young_event.doc, young_event.rev), - mock.call(old_event.doc, old_event.rev), - mock.call(very_old_event.doc, very_old_event.rev), + mock.call(self.young_event.doc, self.young_event.rev), + mock.call(self.old_event.doc, self.old_event.rev), + mock.call(self.very_old_event.doc, self.very_old_event.rev), ], ) mock_create.reset_mock() mock_ensure_path.reset_mock() - + # everything should still be tried, even if there's an exception mock_create.side_effect = RuntimeError generate_draft_bibxml_files_task(process_all=True) @@ -195,8 +170,46 @@ class TaskTests(TestCase): self.assertCountEqual( mock_create.call_args_list, [ - mock.call(young_event.doc, young_event.rev), - mock.call(old_event.doc, old_event.rev), - mock.call(very_old_event.doc, very_old_event.rev), + mock.call(self.young_event.doc, self.young_event.rev), + mock.call(self.old_event.doc, self.old_event.rev), + mock.call(self.very_old_event.doc, self.very_old_event.rev), ], ) + + @mock.patch("ietf.doc.tasks.ensure_draft_bibxml_path_exists") + @mock.patch("ietf.doc.tasks.update_or_create_draft_bibxml_file") + def test_generate_bibxml_files_for_recent_drafts_task(self, mock_create, mock_ensure_path): + # default args - look back 7 days + generate_draft_bibxml_files_task() + self.assertTrue(mock_ensure_path.called) + self.assertCountEqual( + mock_create.call_args_list, [mock.call(self.young_event.doc, self.young_event.rev)] + ) + mock_create.reset_mock() + mock_ensure_path.reset_mock() + + # shorter lookback + generate_draft_bibxml_files_task(days=5) + self.assertTrue(mock_ensure_path.called) + self.assertCountEqual(mock_create.call_args_list, []) + mock_create.reset_mock() + mock_ensure_path.reset_mock() + + # longer lookback + generate_draft_bibxml_files_task(days=9) + self.assertTrue(mock_ensure_path.called) + self.assertCountEqual( + mock_create.call_args_list, + [ + mock.call(self.young_event.doc, self.young_event.rev), + mock.call(self.old_event.doc, self.old_event.rev), + ], + ) + + @mock.patch("ietf.doc.tasks.ensure_draft_bibxml_path_exists") + @mock.patch("ietf.doc.tasks.update_or_create_draft_bibxml_file") + def test_generate_bibxml_files_for_recent_drafts_task_with_bad_value(self, mock_create, mock_ensure_path): + with self.assertRaises(ValueError): + generate_draft_bibxml_files_task(days=0) + self.assertFalse(mock_create.called) + self.assertFalse(mock_ensure_path.called) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index cd0fbb43b..a98b46cb5 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2011-2020, All Rights Reserved +# Copyright The IETF Trust 2011-2024, All Rights Reserved # -*- coding: utf-8 -*- @@ -1228,6 +1228,7 @@ def fuzzy_find_documents(name, rev=None): FoundDocuments = namedtuple('FoundDocuments', 'documents matched_name matched_rev') return FoundDocuments(docs, name, rev) + def bibxml_for_draft(doc, rev=None): if rev is not None and rev != doc.rev: From 7d6d7e1c4460bdbe00647b89f25363cdc4a600b2 Mon Sep 17 00:00:00 2001 From: Russ Housley Date: Wed, 7 Aug 2024 11:12:40 -0400 Subject: [PATCH 10/15] Update session_details_panel.html (#7719) Add rows for interim meeting recordings. Fixes #7699. --- ietf/templates/meeting/session_details_panel.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/templates/meeting/session_details_panel.html b/ietf/templates/meeting/session_details_panel.html index 1dcbded91..808da1438 100644 --- a/ietf/templates/meeting/session_details_panel.html +++ b/ietf/templates/meeting/session_details_panel.html @@ -320,7 +320,7 @@ {% endif %} {# Recordings #} - {% if meeting.number|add:"0" >= 80 %} + {% if meeting.type.slug == 'interim' or meeting.number|add:"0" >= 80 %} {% with session.recordings as recordings %} {% if recordings %} {# There's no guaranteed order, so this is a bit messy: #} @@ -370,4 +370,4 @@ {% endif %} {% endwith %}{% endwith %} -{% endfor %} \ No newline at end of file +{% endfor %} From 4b912d55a540735418007c4d4751ed753dea431f Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 7 Aug 2024 12:14:39 -0300 Subject: [PATCH 11/15] fix: better handle "entered" agendas (#7721) * fix: don't assume file has open method The open method is specific to Django's uploaded file classes, where it just calls seek(0) * refactor: better upload/enter agenda abstraction --- ietf/meeting/utils.py | 22 ++++++++++++++-------- ietf/meeting/views.py | 29 ++++++++++++++--------------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 470795811..e3d8c830e 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -727,7 +727,7 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap def handle_upload_file(file, filename, meeting, subdir, request=None, encoding=None): """Accept an uploaded materials file - This function takes a file object, a filename and a meeting object and subdir as string. + This function takes a _binary mode_ file object, a filename and a meeting object and subdir as string. It saves the file to the appropriate directory, get_materials_path() + subdir. If the file is a zip file, it creates a new directory in 'slides', which is the basename of the zip file and unzips the file in the new directory. @@ -749,9 +749,18 @@ def handle_upload_file(file, filename, meeting, subdir, request=None, encoding=N pass # if the file is already gone, so be it with (path / filename).open('wb+') as destination: + # prep file for reading + if hasattr(file, "chunks"): + chunks = file.chunks() + else: + try: + file.seek(0) + except AttributeError: + pass + chunks = [file.read()] # pretend we have chunks + if filename.suffix in settings.MEETING_VALID_MIME_TYPE_EXTENSIONS['text/html']: - file.open() - text = file.read() + text = b"".join(chunks) if encoding: try: text = text.decode(encoding) @@ -778,11 +787,8 @@ def handle_upload_file(file, filename, meeting, subdir, request=None, encoding=N f"please check the resulting content. " )) else: - if hasattr(file, 'chunks'): - for chunk in file.chunks(): - destination.write(chunk) - else: - destination.write(file.read()) + for chunk in chunks: + destination.write(chunk) # unzip zipfile if is_zipfile: diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index c3494a13b..cf7c67ac4 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -33,6 +33,7 @@ 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.files.uploadedfile import SimpleUploadedFile from django.core.validators import URLValidator from django.urls import reverse,reverse_lazy from django.db.models import F, Max, Q @@ -2795,6 +2796,17 @@ class UploadOrEnterAgendaForm(UploadAgendaForm): elif submission_method == "enter": require_field("content") + def get_file(self): + """Get content as a file-like object""" + if self.cleaned_data.get("submission_method") == "upload": + return self.cleaned_data["file"] + else: + return SimpleUploadedFile( + name="uploaded.md", + content=self.cleaned_data["content"].encode("utf-8"), + content_type="text/markdown;charset=utf-8", + ) + 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) @@ -2815,21 +2827,8 @@ def upload_session_agenda(request, session_id, num): if request.method == 'POST': form = UploadOrEnterAgendaForm(show_apply_to_all_checkbox,request.POST,request.FILES) if form.is_valid(): - 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") + file = form.get_file() + _, ext = os.path.splitext(file.name) apply_to_all = session.type.slug == 'regular' if show_apply_to_all_checkbox: apply_to_all = form.cleaned_data['apply_to_all'] From f921cdba5d81fb2c1152ee13da31cde10c5d685a Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 7 Aug 2024 12:37:26 -0300 Subject: [PATCH 12/15] fix: disable nginx body size check (#7803) * fix: increase nginx client_max_body_size * fix: entirely disable nginx body size check --- k8s/nginx-datatracker.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/k8s/nginx-datatracker.conf b/k8s/nginx-datatracker.conf index ff439fba6..5cbc22e6c 100644 --- a/k8s/nginx-datatracker.conf +++ b/k8s/nginx-datatracker.conf @@ -23,5 +23,6 @@ server { proxy_set_header X-Forwarded-For $${keepempty}proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $${keepempty}remote_addr; proxy_pass http://localhost:8000; + client_max_body_size 0; # disable size check } } From 0c8db80b184b7bf1c12f34b6022bb5d2f24e072e Mon Sep 17 00:00:00 2001 From: Rich Salz Date: Wed, 7 Aug 2024 12:23:18 -0400 Subject: [PATCH 13/15] fix: Show recordings for interims (#7197) * fix: Show recordings for interims Add methods uses_notes(), has_recordings(), and uses_chat_logs() to the meeting object (with semantically correct tests) and use them consistently throughout. List the recordings if the "meeting numnber" starts with "interim" Fixes: #6543 * style: Use "is not" and "is" for None comparisons * None comparison and non-IETF meetings style: Use "is not None" instead of "!=" For non-IETF meetings assume chat logs exist * fix: Restore useNotes for JS fields * fix: uses_notes->useNotes (in JavaScript) Also add comment about meeting number field in tests * Missed a uses_notes->useNotes edit * fix: useNotes->usesNotes --------- Co-authored-by: Jennifer Richards Co-authored-by: Robert Sparks --- client/agenda/AgendaScheduleList.vue | 2 +- client/agenda/store.js | 4 ++-- ietf/doc/views_doc.py | 14 +++++++------- ietf/meeting/models.py | 17 ++++++++++++++++- ietf/meeting/tests_views.py | 2 +- ietf/meeting/views.py | 4 +--- ietf/templates/group/meetings-row.html | 4 ++-- .../meeting/interim_session_buttons.html | 2 +- .../meeting/session_buttons_include.html | 12 ++++++------ .../meeting/session_details_panel.html | 8 ++++---- ietf/templates/meeting/upcoming.html | 2 +- playwright/helpers/meeting.js | 2 +- 12 files changed, 43 insertions(+), 30 deletions(-) diff --git a/client/agenda/AgendaScheduleList.vue b/client/agenda/AgendaScheduleList.vue index 9db763b74..a6d2b9a5a 100644 --- a/client/agenda/AgendaScheduleList.vue +++ b/client/agenda/AgendaScheduleList.vue @@ -296,7 +296,7 @@ const meetingEvents = computed(() => { color: 'red' }) } - if (agendaStore.useNotes) { + if (agendaStore.usesNotes) { links.push({ id: `lnk-${item.id}-note`, label: 'Notepad for note-takers', diff --git a/client/agenda/store.js b/client/agenda/store.js index 839d464c4..18c3e8c65 100644 --- a/client/agenda/store.js +++ b/client/agenda/store.js @@ -50,7 +50,7 @@ export const useAgendaStore = defineStore('agenda', { selectedCatSubs: [], settingsShown: false, timezone: DateTime.local().zoneName, - useNotes: false, + usesNotes: false, visibleDays: [] }), getters: { @@ -160,7 +160,7 @@ export const useAgendaStore = defineStore('agenda', { this.isCurrentMeeting = agendaData.isCurrentMeeting this.meeting = agendaData.meeting this.schedule = agendaData.schedule - this.useNotes = agendaData.useNotes + this.usesNotes = agendaData.usesNotes // -> Compute current info note hash this.infoNoteHash = murmur(agendaData.meeting.infoNote, 0).toString() diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 21c5eb235..bd4927508 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -996,7 +996,7 @@ def document_raw_id(request, name, rev=None, ext=None): for t in possible_types: if os.path.exists(base_path + t): found_types[t]=base_path+t - if ext == None: + if ext is None: ext = 'txt' if not ext in found_types: raise Http404('dont have the file for that extension') @@ -1227,7 +1227,7 @@ def document_bibtex(request, name, rev=None): raise Http404() # Make sure URL_REGEXPS did not grab too much for the rev number - if rev != None and len(rev) != 2: + if rev is not None and len(rev) != 2: mo = re.search(r"^(?P[0-9]{1,2})-(?P[0-9]{2})$", rev) if mo: name = name+"-"+mo.group(1) @@ -1250,7 +1250,7 @@ def document_bibtex(request, name, rev=None): replaced_by = [d.name for d in doc.related_that("replaces")] draft_became_rfc = doc.became_rfc() - if rev != None and rev != doc.rev: + if rev is not None and rev != doc.rev: # find the entry in the history for h in doc.history_set.order_by("-time"): if rev == h.rev: @@ -1291,7 +1291,7 @@ def document_bibxml(request, name, rev=None): raise Http404() # Make sure URL_REGEXPS did not grab too much for the rev number - if rev != None and len(rev) != 2: + if rev is not None and len(rev) != 2: mo = re.search(r"^(?P[0-9]{1,2})-(?P[0-9]{2})$", rev) if mo: name = name+"-"+mo.group(1) @@ -1439,7 +1439,7 @@ def document_referenced_by(request, name): if doc.type_id in ["bcp","std","fyi"]: for rfc in doc.contains(): refs |= rfc.referenced_by() - full = ( request.GET.get('full') != None ) + full = ( request.GET.get('full') is not None ) numdocs = refs.count() if not full and numdocs>250: refs=refs[:250] @@ -1459,7 +1459,7 @@ def document_ballot_content(request, doc, ballot_id, editable=True): augment_events_with_revision(doc, all_ballots) ballot = None - if ballot_id != None: + if ballot_id is not None: ballot_id = int(ballot_id) for b in all_ballots: if b.id == ballot_id: @@ -1661,7 +1661,7 @@ def add_comment(request, name): login = request.user.person - if doc.type_id == "draft" and doc.group != None: + if doc.type_id == "draft" and doc.group is not None: can_add_comment = bool(has_role(request.user, ("Area Director", "Secretariat", "IRTF Chair", "IANA", "RFC Editor")) or ( request.user.is_authenticated and Role.objects.filter(name__in=("chair", "secr"), diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index fa1ad9d67..d8a069ef3 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -383,7 +383,22 @@ class Meeting(models.Model): return Meeting.objects.filter(type_id=self.type_id,date__lt=self.date).order_by('-date').first() def uses_notes(self): - return self.date>=datetime.date(2020,7,6) + if self.type_id != 'ietf': + return True + num = self.get_number() + return num is not None and num >= 108 + + def has_recordings(self): + if self.type_id != 'ietf': + return True + num = self.get_number() + return num is not None and num >= 80 + + def has_chat_logs(self): + if self.type_id != 'ietf': + return True; + num = self.get_number() + return num is not None and num >= 60 def meeting_start(self): """Meeting-local midnight at the start of the meeting date""" diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 60eef96b2..e4f62838d 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -259,7 +259,7 @@ class MeetingTests(BaseMeetingTestCase): }, "categories": rjson.get("categories"), # Just expect the value to exist "isCurrentMeeting": True, - "useNotes": True, + "usesNotes": False, # make_meeting_test_data sets number=72 "schedule": rjson.get("schedule"), # Just expect the value to exist "floors": [] } diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index cf7c67ac4..211cdec9a 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1617,7 +1617,6 @@ def agenda_plain(request, num=None, name=None, base=None, ext=None, owner=None, "now": timezone.now().astimezone(meeting.tz()), "display_timezone": display_timezone, "is_current_meeting": is_current_meeting, - "use_notes": meeting.uses_notes(), "cache_time": 150 if is_current_meeting else 3600, }, content_type=mimetype[ext], @@ -1692,7 +1691,7 @@ def api_get_agenda_data (request, num=None): }, "categories": filter_organizer.get_filter_categories(), "isCurrentMeeting": is_current_meeting, - "useNotes": meeting.uses_notes(), + "usesNotes": meeting.uses_notes(), "schedule": list(map(agenda_extract_schedule, filtered_assignments)), "floors": list(map(agenda_extract_floorplan, floors)) }) @@ -2489,7 +2488,6 @@ def session_details(request, num, acronym): 'can_manage_materials' : can_manage, 'can_view_request': can_view_request, 'thisweek': datetime_today()-datetime.timedelta(days=7), - 'use_notes': meeting.uses_notes(), }) class SessionDraftsForm(forms.Form): diff --git a/ietf/templates/group/meetings-row.html b/ietf/templates/group/meetings-row.html index 57c727eea..3bbe1d425 100644 --- a/ietf/templates/group/meetings-row.html +++ b/ietf/templates/group/meetings-row.html @@ -78,9 +78,9 @@
{# see note in the included templates re: show_agenda parameter and required JS import #} {% if s.meeting.type.slug == 'interim' %} - {% include "meeting/interim_session_buttons.html" with show_agenda=False show_empty=False session=s meeting=s.meeting use_notes=s.meeting.use_notes %} + {% include "meeting/interim_session_buttons.html" with show_agenda=False show_empty=False session=s meeting=s.meeting %} {% else %} - {% include "meeting/session_buttons_include.html" with show_agenda=False item=s.official_timeslotassignment session=s meeting=s.meeting use_notes=s.meeting.use_notes %} + {% include "meeting/session_buttons_include.html" with show_agenda=False item=s.official_timeslotassignment session=s meeting=s.meeting %} {% endif %}
{% endif %} diff --git a/ietf/templates/meeting/interim_session_buttons.html b/ietf/templates/meeting/interim_session_buttons.html index 2f0951338..a32f4345c 100644 --- a/ietf/templates/meeting/interim_session_buttons.html +++ b/ietf/templates/meeting/interim_session_buttons.html @@ -34,7 +34,7 @@ {% endif %} {# notes #} - {% if use_notes %} + {% if session.agenda.uses_notes %} {% endif %} {# Notes #} - {% if use_notes %} + {% if meeting.uses_notes %} {% else %} {# chat logs #} - {% if meeting.number|add:"0" >= 60 %} + {% if meeting.has_chat_logs %} {% endif %} {# Recordings #} - {% if meeting.number|add:"0" >= 80 %} + {% if meeting.has_recordings %} {% with session.recordings as recordings %} {% if recordings %} {# There's no guaranteed order, so this is a bit messy: #} @@ -229,7 +229,7 @@ {% endif %} {# Notes #} - {% if use_notes %} + {% if meeting.uses_notes %}
  • Notepad for note-takers @@ -303,7 +303,7 @@
  • {% else %} {# chat logs #} - {% if meeting.number|add:"0" >= 60 %} + {% if meeting.has_chat_logs %}
  • @@ -312,7 +312,7 @@
  • {% endif %} {# Recordings #} - {% if meeting.number|add:"0" >= 80 %} + {% if meeting.has_recordings %} {% with session.recordings as recordings %} {% if recordings %} {# There's no guaranteed order, so this is a bit messy: #} diff --git a/ietf/templates/meeting/session_details_panel.html b/ietf/templates/meeting/session_details_panel.html index 808da1438..52aeaaa8c 100644 --- a/ietf/templates/meeting/session_details_panel.html +++ b/ietf/templates/meeting/session_details_panel.html @@ -9,7 +9,7 @@ {% if meeting.type.slug == 'interim' %} {% include "meeting/interim_session_buttons.html" with show_agenda=False show_empty=False %} {% else %} - {% include "meeting/session_buttons_include.html" with show_agenda=False item=session.official_timeslotassignment use_notes=session.meeting.use_notes %} + {% include "meeting/session_buttons_include.html" with show_agenda=False item=session.official_timeslotassignment %} {% endif %} {% endif %} @@ -230,7 +230,7 @@ - {% if use_notes %} + {% if meeting.uses_notes %}
    @@ -310,7 +310,7 @@ - {% if use_notes %} + {% if session.uses_notes %} {% endif %} {# Recordings #} - {% if meeting.type.slug == 'interim' or meeting.number|add:"0" >= 80 %} + {% if session.has_recordings %} {% with session.recordings as recordings %} {% if recordings %} {# There's no guaranteed order, so this is a bit messy: #} diff --git a/ietf/templates/meeting/upcoming.html b/ietf/templates/meeting/upcoming.html index 802b1b03c..13a27ed91 100644 --- a/ietf/templates/meeting/upcoming.html +++ b/ietf/templates/meeting/upcoming.html @@ -89,7 +89,7 @@ Cancelled {% else %} - + {% endif %} {% endwith %} {% else %} diff --git a/playwright/helpers/meeting.js b/playwright/helpers/meeting.js index f07228b47..52bc331fd 100644 --- a/playwright/helpers/meeting.js +++ b/playwright/helpers/meeting.js @@ -630,7 +630,7 @@ module.exports = { }, categories, isCurrentMeeting: dateMode !== 'past', - useNotes: true, + usesNotes: true, schedule, floors } From 30970749e30d89111bc58f4ef80c76ef88c70397 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Wed, 7 Aug 2024 20:25:08 +0200 Subject: [PATCH 14/15] fix: Send create user email for password resets where we have an email and person, but no user. (#7729) * fix: Send create user email for password resets where we have an email and person, but no user account This fixes https://github.com/ietf-tools/datatracker/issues/6458 * fix: create User straight away and use nomral password reset --------- Co-authored-by: Robert Sparks --- ietf/ietfauth/tests.py | 18 ++++++++++++++++++ ietf/ietfauth/views.py | 16 +++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index 6a85c6eb1..722c1e8b6 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -527,6 +527,24 @@ class IetfAuthTests(TestCase): self.assertIn(secondary_address, to) self.assertNotIn(inactive_secondary_address, to) + def test_reset_password_without_user(self): + """Reset password using email address for person without a user account""" + url = urlreverse('ietf.ietfauth.views.password_reset') + email = EmailFactory() + person = email.person + # Remove the user object from the person to get a Email/Person without User: + person.user = None + person.save() + # Remove the remaining User record, since reset_password looks for that by username: + User.objects.filter(username__iexact=email.address).delete() + empty_outbox() + r = self.client.post(url, { 'username': email.address }) + self.assertEqual(len(outbox), 1) + lastReceivedEmail = outbox[-1] + self.assertIn(email.address, lastReceivedEmail.get('To')) + self.assertTrue(lastReceivedEmail.get('Subject').startswith("Confirm password reset")) + self.assertContains(r, "Your password reset request has been successfully received", status_code=200) + def test_review_overview(self): review_req = ReviewRequestFactory() assignment = ReviewAssignmentFactory(review_request=review_req,reviewer=EmailFactory(person__user__username='reviewer')) diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 61c7b929b..32bb5c92b 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -491,9 +491,19 @@ def password_reset(request): if not user: # try to find user ID from the email address email = Email.objects.filter(address=submitted_username).first() - if email and email.person and email.person.user: - user = email.person.user - + if email and email.person: + if email.person.user: + user = email.person.user + else: + # Create a User record with this (conditioned by way of Email) username + # Don't bother setting the name or email fields on User - rely on the + # Person pointer. + user = User.objects.create( + username=email.address.lower(), + is_active=True, + ) + email.person.user = user + email.person.save() if user and user.person.email_set.filter(active=True).exists(): data = { 'username': user.username, From e5e6c9bc89b227e2587a831c1a40d386147acb39 Mon Sep 17 00:00:00 2001 From: Matthew Holloway Date: Thu, 8 Aug 2024 06:36:21 +1200 Subject: [PATCH 15/15] feat: Site status message (#7659) * Status WIP * feat: Status * fix: Status tests * feat: status redirect * chore: Status tests * chore: Status tests * feat: Status tests * chore: Status playwright tests * fix: PR feedback, mostly Vue and copyright dates * fix: Status model migration tidy up * chore: Status - one migration * feat: status on doc/html pages * chore: Resetting Status migration * chore: removing unused FieldError * fix: Update Status test to remove 'by' * chore: fixing API test to exclude 'status' * chore: fixing status_page test * feat: Site Status PR feedback. URL coverage debugging * Adding ietf.status to Tastypie omitted apps * feat: Site Status PR feedback * chore: correct copyright year on newly created files * chore: repair merge damage * chore: repair more merge damage * fix: reconcile the api init refactor with ignoring apps --------- Co-authored-by: Matthew Holloway Co-authored-by: Robert Sparks --- client/Embedded.vue | 8 +- client/components/Status.vue | 80 +++++++++++++++++ client/index.html | 2 + client/shared/json-wrapper.js | 20 +++++ client/shared/local-storage-wrapper.js | 42 +++++++++ client/shared/status-common.js | 5 ++ ietf/api/__init__.py | 6 +- ietf/api/tests.py | 1 + ietf/settings.py | 1 + ietf/static/css/ietf.scss | 7 ++ ietf/status/__init__.py | 0 ietf/status/admin.py | 19 ++++ ietf/status/apps.py | 9 ++ ietf/status/migrations/0001_initial.py | 75 ++++++++++++++++ ietf/status/migrations/__init__.py | 0 ietf/status/models.py | 24 +++++ ietf/status/tests.py | 120 +++++++++++++++++++++++++ ietf/status/urls.py | 12 +++ ietf/status/views.py | 46 ++++++++++ ietf/templates/base.html | 5 +- ietf/templates/base/status.html | 2 + ietf/templates/doc/document_html.html | 3 + ietf/templates/status/latest.html | 18 ++++ ietf/templates/status/status.html | 15 ++++ ietf/urls.py | 1 + playwright/tests/status/status.spec.js | 61 +++++++++++++ 26 files changed, 574 insertions(+), 8 deletions(-) create mode 100644 client/components/Status.vue create mode 100644 client/shared/json-wrapper.js create mode 100644 client/shared/local-storage-wrapper.js create mode 100644 client/shared/status-common.js create mode 100644 ietf/status/__init__.py create mode 100644 ietf/status/admin.py create mode 100644 ietf/status/apps.py create mode 100644 ietf/status/migrations/0001_initial.py create mode 100644 ietf/status/migrations/__init__.py create mode 100644 ietf/status/models.py create mode 100644 ietf/status/tests.py create mode 100644 ietf/status/urls.py create mode 100644 ietf/status/views.py create mode 100644 ietf/templates/base/status.html create mode 100644 ietf/templates/status/latest.html create mode 100644 ietf/templates/status/status.html create mode 100644 playwright/tests/status/status.spec.js diff --git a/client/Embedded.vue b/client/Embedded.vue index a0f0d2831..80b105dc1 100644 --- a/client/Embedded.vue +++ b/client/Embedded.vue @@ -1,12 +1,13 @@ diff --git a/client/index.html b/client/index.html index 740c99432..75d6f7772 100644 --- a/client/index.html +++ b/client/index.html @@ -12,6 +12,7 @@ +
    @@ -20,5 +21,6 @@
    + diff --git a/client/shared/json-wrapper.js b/client/shared/json-wrapper.js new file mode 100644 index 000000000..e080b5a47 --- /dev/null +++ b/client/shared/json-wrapper.js @@ -0,0 +1,20 @@ +export const JSONWrapper = { + parse(jsonString, defaultValue) { + if(typeof jsonString !== "string") { + return defaultValue + } + try { + return JSON.parse(jsonString); + } catch (e) { + console.error(e); + } + return defaultValue + }, + stringify(data) { + try { + return JSON.stringify(data); + } catch (e) { + console.error(e) + } + }, +} diff --git a/client/shared/local-storage-wrapper.js b/client/shared/local-storage-wrapper.js new file mode 100644 index 000000000..88cd3dc58 --- /dev/null +++ b/client/shared/local-storage-wrapper.js @@ -0,0 +1,42 @@ + +/* + * DEVELOPER NOTE + * + * Some browsers can block storage (localStorage, sessionStorage) + * access for privacy reasons, and all browsers can have storage + * that's full, and then they throw exceptions. + * + * See https://michalzalecki.com/why-using-localStorage-directly-is-a-bad-idea/ + * + * Exceptions can even be thrown when testing if localStorage + * even exists. This can throw: + * + * if (window.localStorage) + * + * Also localStorage/sessionStorage can be enabled after DOMContentLoaded + * so we handle it gracefully. + * + * 1) we need to wrap all usage in try/catch + * 2) we need to defer actual usage of these until + * necessary, + * + */ + +export const localStorageWrapper = { + getItem: (key) => { + try { + return localStorage.getItem(key) + } catch (e) { + console.error(e); + } + return null; + }, + setItem: (key, value) => { + try { + return localStorage.setItem(key, value) + } catch (e) { + console.error(e); + } + return; + }, +} diff --git a/client/shared/status-common.js b/client/shared/status-common.js new file mode 100644 index 000000000..6503bfbf6 --- /dev/null +++ b/client/shared/status-common.js @@ -0,0 +1,5 @@ +// Used in Playwright Status and components + +export const STATUS_STORAGE_KEY = "status-dismissed" + +export const generateStatusTestId = (id) => `status-${id}` diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py index 81f370121..9fadab8e6 100644 --- a/ietf/api/__init__.py +++ b/ietf/api/__init__.py @@ -21,14 +21,14 @@ from tastypie.bundle import Bundle from tastypie.exceptions import ApiFieldError from tastypie.fields import ApiField - _api_list = [] +OMITTED_APPS_APIS = ["ietf.status"] def populate_api_list(): + _module_dict = globals() for app_config in django_apps.get_app_configs(): - _module_dict = globals() - if '.' in app_config.name: + if '.' in app_config.name and app_config.name not in OMITTED_APPS_APIS: _root, _name = app_config.name.split('.', 1) if _root == 'ietf': if not '.' in _name: diff --git a/ietf/api/tests.py b/ietf/api/tests.py index fd8eb52cd..20c3e2cb4 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -48,6 +48,7 @@ OMITTED_APPS = ( 'ietf.secr.meetings', 'ietf.secr.proceedings', 'ietf.ipr', + 'ietf.status', ) class CustomApiTests(TestCase): diff --git a/ietf/settings.py b/ietf/settings.py index 7572b1521..db53efe0a 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -479,6 +479,7 @@ INSTALLED_APPS = [ 'ietf.release', 'ietf.review', 'ietf.stats', + 'ietf.status', 'ietf.submit', 'ietf.sync', 'ietf.utils', diff --git a/ietf/static/css/ietf.scss b/ietf/static/css/ietf.scss index 5bd520f04..e2d5cb395 100644 --- a/ietf/static/css/ietf.scss +++ b/ietf/static/css/ietf.scss @@ -1189,6 +1189,13 @@ blockquote { border-left: solid 1px var(--bs-body-color); } +iframe.status { + background-color:transparent; + border:none; + width:100%; + height:3.5em; +} + .overflow-shadows { transition: box-shadow 0.5s; } diff --git a/ietf/status/__init__.py b/ietf/status/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/status/admin.py b/ietf/status/admin.py new file mode 100644 index 000000000..f9c4e891a --- /dev/null +++ b/ietf/status/admin.py @@ -0,0 +1,19 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from datetime import datetime +from django.contrib import admin +from django.template.defaultfilters import slugify +from .models import Status + +class StatusAdmin(admin.ModelAdmin): + list_display = ['title', 'body', 'active', 'date', 'by', 'page'] + raw_id_fields = ['by'] + + def get_changeform_initial_data(self, request): + date = datetime.now() + return { + "slug": slugify(f"{date.year}-{date.month}-{date.day}-"), + } + +admin.site.register(Status, StatusAdmin) diff --git a/ietf/status/apps.py b/ietf/status/apps.py new file mode 100644 index 000000000..ba64a41af --- /dev/null +++ b/ietf/status/apps.py @@ -0,0 +1,9 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from django.apps import AppConfig + + +class StatusConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "ietf.status" diff --git a/ietf/status/migrations/0001_initial.py b/ietf/status/migrations/0001_initial.py new file mode 100644 index 000000000..518518949 --- /dev/null +++ b/ietf/status/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.13 on 2024-07-21 22:47 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("person", "0002_alter_historicalperson_ascii_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Status", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateTimeField(default=django.utils.timezone.now)), + ("slug", models.SlugField(unique=True)), + ( + "title", + models.CharField( + help_text="Your site status notification title.", + max_length=255, + verbose_name="Status title", + ), + ), + ( + "body", + models.CharField( + help_text="Your site status notification body.", + max_length=255, + verbose_name="Status body", + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Only active messages will be shown.", + verbose_name="Active?", + ), + ), + ( + "page", + models.TextField( + blank=True, + help_text="More detail shown after people click 'Read more'. If empty no 'read more' will be shown", + null=True, + verbose_name="More detail (markdown)", + ), + ), + ( + "by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="person.person" + ), + ), + ], + options={ + "verbose_name_plural": "statuses", + }, + ), + ] diff --git a/ietf/status/migrations/__init__.py b/ietf/status/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/status/models.py b/ietf/status/models.py new file mode 100644 index 000000000..b3f97d989 --- /dev/null +++ b/ietf/status/models.py @@ -0,0 +1,24 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from django.utils import timezone +from django.db import models +from django.db.models import ForeignKey + +import debug # pyflakes:ignore + +class Status(models.Model): + name = 'Status' + + date = models.DateTimeField(default=timezone.now) + slug = models.SlugField(blank=False, null=False, unique=True) + title = models.CharField(max_length=255, verbose_name="Status title", help_text="Your site status notification title.") + body = models.CharField(max_length=255, verbose_name="Status body", help_text="Your site status notification body.", unique=False) + active = models.BooleanField(default=True, verbose_name="Active?", help_text="Only active messages will be shown.") + by = ForeignKey('person.Person', on_delete=models.CASCADE) + page = models.TextField(blank=True, null=True, verbose_name="More detail (markdown)", help_text="More detail shown after people click 'Read more'. If empty no 'read more' will be shown") + + def __str__(self): + return "{} {} {} {}".format(self.date, self.active, self.by, self.title) + class Meta: + verbose_name_plural = "statuses" diff --git a/ietf/status/tests.py b/ietf/status/tests.py new file mode 100644 index 000000000..9c0dd9114 --- /dev/null +++ b/ietf/status/tests.py @@ -0,0 +1,120 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +import debug # pyflakes:ignore + +from django.urls import reverse as urlreverse +from ietf.utils.test_utils import TestCase +from ietf.person.models import Person +from ietf.status.models import Status + +class StatusTests(TestCase): + def test_status_latest_html(self): + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = "2024-1-1-my-title-1" + ) + status.save() + + url = urlreverse('ietf.status.views.status_latest_html') + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'my title 1') + self.assertContains(r, 'my body 1') + + status.delete() + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, 'my title 1') + self.assertNotContains(r, 'my body 1') + + def test_status_latest_json(self): + url = urlreverse('ietf.status.views.status_latest_json') + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertFalse(data["hasMessage"]) + + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = "2024-1-1-my-title-1" + ) + status.save() + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertTrue(data["hasMessage"]) + self.assertEqual(data["title"], "my title 1") + self.assertEqual(data["body"], "my body 1") + self.assertEqual(data["slug"], '2024-1-1-my-title-1') + self.assertEqual(data["url"], '/status/2024-1-1-my-title-1') + + status.delete() + + def test_status_latest_redirect(self): + url = urlreverse('ietf.status.views.status_latest_redirect') + r = self.client.get(url) + # without a Status it should return Not Found + self.assertEqual(r.status_code, 404) + + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = "2024-1-1-my-title-1" + ) + status.save() + + r = self.client.get(url) + # with a Status it should redirect + self.assertEqual(r.status_code, 302) + self.assertEqual(r.headers["Location"], "/status/2024-1-1-my-title-1") + + status.delete() + + def test_status_page(self): + slug = "2024-1-1-my-unique-slug" + r = self.client.get(f'/status/{slug}/') + # without a Status it should return Not Found + self.assertEqual(r.status_code, 404) + + # status without `page` markdown should still 200 + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = slug + ) + status.save() + + r = self.client.get(f'/status/{slug}/') + self.assertEqual(r.status_code, 200) + + status.delete() + + test_string = 'a string that' + status = Status.objects.create( + title = "my title 1", + body = "my body 1", + active = True, + by = Person.objects.get(user__username='ad'), + slug = slug, + page = f"# {test_string}" + ) + status.save() + + r = self.client.get(f'/status/{slug}/') + self.assertEqual(r.status_code, 200) + self.assertContains(r, test_string) + + status.delete() diff --git a/ietf/status/urls.py b/ietf/status/urls.py new file mode 100644 index 000000000..060c0257e --- /dev/null +++ b/ietf/status/urls.py @@ -0,0 +1,12 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from ietf.status import views +from ietf.utils.urls import url + +urlpatterns = [ + url(r"^$", views.status_latest_redirect), + url(r"^latest$", views.status_latest_html), + url(r"^latest.json$", views.status_latest_json), + url(r"(?P.*)", views.status_page) +] diff --git a/ietf/status/views.py b/ietf/status/views.py new file mode 100644 index 000000000..9037d01dc --- /dev/null +++ b/ietf/status/views.py @@ -0,0 +1,46 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from django.urls import reverse as urlreverse +from django.http import HttpResponseRedirect, HttpResponseNotFound, JsonResponse +from ietf.utils import markdown +from django.shortcuts import render, get_object_or_404 +from ietf.status.models import Status + +import debug # pyflakes:ignore + +def get_last_active_status(): + status = Status.objects.filter(active=True).order_by("-date").first() + if status is None: + return { "hasMessage": False } + + context = { + "hasMessage": True, + "id": status.id, + "slug": status.slug, + "title": status.title, + "body": status.body, + "url": urlreverse("ietf.status.views.status_page", kwargs={ "slug": status.slug }), + "date": status.date.isoformat() + } + return context + +def status_latest_html(request): + return render(request, "status/latest.html", context=get_last_active_status()) + +def status_page(request, slug): + sanitised_slug = slug.rstrip("/") + status = get_object_or_404(Status, slug=sanitised_slug) + return render(request, "status/status.html", context={ + 'status': status, + 'status_page_html': markdown.markdown(status.page or ""), + }) + +def status_latest_json(request): + return JsonResponse(get_last_active_status()) + +def status_latest_redirect(request): + context = get_last_active_status() + if context["hasMessage"] == True: + return HttpResponseRedirect(context["url"]) + return HttpResponseNotFound() diff --git a/ietf/templates/base.html b/ietf/templates/base.html index f426d361c..ceb1d2df0 100644 --- a/ietf/templates/base.html +++ b/ietf/templates/base.html @@ -34,6 +34,7 @@ {% analytical_body_top %} + {% include "base/status.html" %} Skip to main content {% block precontent %}{% endblock %} -
    +
    {% if request.COOKIES.left_menu == "on" and not hide_menu %}
    @@ -114,7 +115,7 @@ {% block content_end %}{% endblock %}
    -
    + {% block footer %}
    IETF diff --git a/ietf/templates/base/status.html b/ietf/templates/base/status.html new file mode 100644 index 000000000..33e1abf69 --- /dev/null +++ b/ietf/templates/base/status.html @@ -0,0 +1,2 @@ + +
    \ No newline at end of file diff --git a/ietf/templates/doc/document_html.html b/ietf/templates/doc/document_html.html index 40223d0fd..3eabe2ebb 100644 --- a/ietf/templates/doc/document_html.html +++ b/ietf/templates/doc/document_html.html @@ -4,6 +4,7 @@ {% load origin %} {% load static %} {% load ietf_filters textfilters %} +{% load django_vite %} {% origin %} @@ -28,6 +29,7 @@ {% if html %} {% endif %} + {% vite_asset 'client/embedded.js' %} {% endif %} @@ -51,6 +53,7 @@ {% analytical_body_top %} + {% include "base/status.html" %}
    @@ -320,7 +320,7 @@
    {% include "meeting/interim_session_buttons.html" with show_agenda=True use_notes=meeting.uses_notes %}{% include "meeting/interim_session_buttons.html" with show_agenda=True %}