diff --git a/client/agenda/AgendaScheduleList.vue b/client/agenda/AgendaScheduleList.vue index 0cac7e39ee..e7c14797ee 100644 --- a/client/agenda/AgendaScheduleList.vue +++ b/client/agenda/AgendaScheduleList.vue @@ -284,6 +284,7 @@ const meetingEvents = computed(() => { const purposesWithoutLinks = ['admin', 'closed_meeting', 'officehours', 'social'] if (item.flags.showAgenda || (typesWithLinks.includes(item.type) && !purposesWithoutLinks.includes(item.purpose))) { if (item.flags.agenda) { + // -> Meeting Materials links.push({ id: `lnk-${item.id}-tar`, label: 'Download meeting materials as .tar archive', @@ -305,7 +306,18 @@ const meetingEvents = computed(() => { color: 'red' }) } - if (agendaStore.usesNotes) { + // -> Point to Wiki for Hackathon sessions, HedgeDocs otherwise + if (item.name.toLowerCase().includes('hackathon')) { + links.push({ + id: `lnk-${item.id}-wiki`, + label: 'Wiki', + icon: 'book', + href: getUrl('hackathonWiki', { + meetingNumber: agendaStore.meeting.number + }), + color: 'blue' + }) + } else if (agendaStore.usesNotes) { links.push({ id: `lnk-${item.id}-note`, label: 'Notepad for note-takers', diff --git a/client/shared/urls.json b/client/shared/urls.json index 285caa07d2..15410d68df 100644 --- a/client/shared/urls.json +++ b/client/shared/urls.json @@ -1,5 +1,6 @@ { "bofDefinition": "https://www.ietf.org/how/bofs/", + "hackathonWiki": "https://wiki.ietf.org/meeting/{meetingNumber}/hackathon", "meetingCalIcs": "/meeting/{meetingNumber}/agenda.ics", "meetingDetails": "/meeting/{meetingNumber}/session/{eventAcronym}/", "meetingMaterialsPdf": "/meeting/{meetingNumber}/agenda/{eventAcronym}-drafts.pdf", diff --git a/dev/deploy-to-container/package-lock.json b/dev/deploy-to-container/package-lock.json index d787f6aa9e..08b6eb6c62 100644 --- a/dev/deploy-to-container/package-lock.json +++ b/dev/deploy-to-container/package-lock.json @@ -8,7 +8,7 @@ "dependencies": { "dockerode": "^4.0.2", "fs-extra": "^11.2.0", - "nanoid": "5.0.8", + "nanoid": "5.0.9", "nanoid-dictionary": "5.0.0-beta.1", "slugify": "1.6.6", "tar": "^7.4.3", @@ -546,9 +546,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.8.tgz", - "integrity": "sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", "funding": [ { "type": "github", @@ -1346,9 +1346,9 @@ "optional": true }, "nanoid": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.8.tgz", - "integrity": "sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==" + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==" }, "nanoid-dictionary": { "version": "5.0.0-beta.1", diff --git a/dev/deploy-to-container/package.json b/dev/deploy-to-container/package.json index 530d1f3b8b..8f16279cea 100644 --- a/dev/deploy-to-container/package.json +++ b/dev/deploy-to-container/package.json @@ -4,7 +4,7 @@ "dependencies": { "dockerode": "^4.0.2", "fs-extra": "^11.2.0", - "nanoid": "5.0.8", + "nanoid": "5.0.9", "nanoid-dictionary": "5.0.0-beta.1", "slugify": "1.6.6", "tar": "^7.4.3", diff --git a/ietf/community/apps.py b/ietf/community/apps.py new file mode 100644 index 0000000000..ab0a6d6054 --- /dev/null +++ b/ietf/community/apps.py @@ -0,0 +1,12 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +from django.apps import AppConfig + + +class CommunityConfig(AppConfig): + name = "ietf.community" + + def ready(self): + """Initialize the app after the registry is populated""" + # implicitly connects @receiver-decorated signals + from . import signals # pyflakes: ignore diff --git a/ietf/community/models.py b/ietf/community/models.py index 0407730107..6945918f9a 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -1,19 +1,14 @@ # Copyright The IETF Trust 2012-2020, All Rights Reserved # -*- coding: utf-8 -*- - -from django.conf import settings -from django.db import models, transaction -from django.db.models import signals +from django.db import models from django.urls import reverse as urlreverse -from ietf.doc.models import Document, DocEvent, State +from ietf.doc.models import Document, State from ietf.group.models import Group from ietf.person.models import Person, Email from ietf.utils.models import ForeignKey -from .tasks import notify_event_to_subscribers_task - class CommunityList(models.Model): person = ForeignKey(Person, blank=True, null=True) @@ -98,29 +93,3 @@ class EmailSubscription(models.Model): def __str__(self): return "%s to %s (%s changes)" % (self.email, self.community_list, self.notify_on) - - -def notify_events(sender, instance, **kwargs): - if not isinstance(instance, DocEvent): - return - - if not kwargs.get("created", False): - return # only notify on creation - - if instance.doc.type_id != 'draft': - return - - if getattr(instance, "skip_community_list_notification", False): - return - - # kludge alert: queuing a celery task in response to a signal can cause unexpected attempts to - # start a Celery task during tests. To prevent this, don't queue a celery task if we're running - # tests. - if settings.SERVER_MODE != "test": - # Wrap in on_commit in case a transaction is open - transaction.on_commit( - lambda: notify_event_to_subscribers_task.delay(event_id=instance.pk) - ) - - -signals.post_save.connect(notify_events) diff --git a/ietf/community/signals.py b/ietf/community/signals.py new file mode 100644 index 0000000000..20ee761129 --- /dev/null +++ b/ietf/community/signals.py @@ -0,0 +1,44 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +from django.conf import settings +from django.db import transaction +from django.db.models.signals import post_save +from django.dispatch import receiver + +from ietf.doc.models import DocEvent +from .tasks import notify_event_to_subscribers_task + + +def notify_of_event(event: DocEvent): + """Send subscriber notification emails for a 'draft'-related DocEvent + + If the event is attached to a draft of type 'doc', queues a task to send notification emails to + community list subscribers. No emails will be sent when SERVER_MODE is 'test'. + """ + if event.doc.type_id != "draft": + return + + if getattr(event, "skip_community_list_notification", False): + return + + # kludge alert: queuing a celery task in response to a signal can cause unexpected attempts to + # start a Celery task during tests. To prevent this, don't queue a celery task if we're running + # tests. + if settings.SERVER_MODE != "test": + # Wrap in on_commit in case a transaction is open + transaction.on_commit( + lambda: notify_event_to_subscribers_task.delay(event_id=event.pk) + ) + + +# dispatch_uid ensures only a single signal receiver binding is made +@receiver(post_save, dispatch_uid="notify_of_events_receiver_uid") +def notify_of_events_receiver(sender, instance, **kwargs): + """Call notify_of_event after saving a new DocEvent""" + if not isinstance(instance, DocEvent): + return + + if not kwargs.get("created", False): + return # only notify on creation + + notify_of_event(instance) diff --git a/ietf/community/tests.py b/ietf/community/tests.py index 743242f11b..9bd7789958 100644 --- a/ietf/community/tests.py +++ b/ietf/community/tests.py @@ -1,7 +1,6 @@ # Copyright The IETF Trust 2016-2023, All Rights Reserved # -*- coding: utf-8 -*- - import mock from pyquery import PyQuery @@ -11,6 +10,7 @@ import debug # pyflakes:ignore from ietf.community.models import CommunityList, SearchRule, EmailSubscription +from ietf.community.signals import notify_of_event from ietf.community.utils import docs_matching_community_list_rule, community_list_rules_matching_doc from ietf.community.utils import reset_name_contains_index_for_rule, notify_event_to_subscribers from ietf.community.tasks import notify_event_to_subscribers_task @@ -431,53 +431,58 @@ def test_subscription_for_group(self): r = self.client.get(url) self.assertEqual(r.status_code, 200) - # Mock out the on_commit call so we can tell whether the task was actually queued - @mock.patch("ietf.submit.views.transaction.on_commit", side_effect=lambda x: x()) - @mock.patch("ietf.community.models.notify_event_to_subscribers_task") - def test_notification_signal_receiver(self, mock_notify_task, mock_on_commit): - """Saving a DocEvent should notify subscribers + @mock.patch("ietf.community.signals.notify_of_event") + def test_notification_signal_receiver(self, mock_notify_of_event): + """Saving a newly created DocEvent should notify subscribers - This implicitly tests that notify_events is hooked up to the post_save signal. + This implicitly tests that notify_of_event_receiver is hooked up to the post_save signal. """ # Arbitrary model that's not a DocEvent - person = PersonFactory() - mock_notify_task.reset_mock() # clear any calls that resulted from the factories - # be careful overriding SERVER_MODE - we do it here because the method - # under test does not make this call when in "test" mode - with override_settings(SERVER_MODE="not-test"): - person.save() - self.assertFalse(mock_notify_task.delay.called) - + person = PersonFactory.build() # builds but does not save... + mock_notify_of_event.reset_mock() # clear any calls that resulted from the factories + person.save() + self.assertFalse(mock_notify_of_event.called) + # build a DocEvent that is not yet persisted doc = DocumentFactory() - d = DocEventFactory.build(by=person, doc=doc) - # mock_notify_task.reset_mock() # clear any calls that resulted from the factories + event = DocEventFactory.build(by=person, doc=doc) # builds but does not save... + mock_notify_of_event.reset_mock() # clear any calls that resulted from the factories + event.save() + self.assertEqual(mock_notify_of_event.call_count, 1, "notify_task should be run on creation of DocEvent") + self.assertEqual(mock_notify_of_event.call_args, mock.call(event)) + + # save the existing DocEvent and see that no notification is sent + mock_notify_of_event.reset_mock() + event.save() + self.assertFalse(mock_notify_of_event.called, "notify_task should not be run save of on existing DocEvent") + + # Mock out the on_commit call so we can tell whether the task was actually queued + @mock.patch("ietf.submit.views.transaction.on_commit", side_effect=lambda x: x()) + @mock.patch("ietf.community.signals.notify_event_to_subscribers_task") + def test_notify_of_event(self, mock_notify_task, mock_on_commit): + """The community notification task should be called as intended""" + person = PersonFactory() # builds but does not save... + doc = DocumentFactory() + event = DocEventFactory(by=person, doc=doc) # be careful overriding SERVER_MODE - we do it here because the method # under test does not make this call when in "test" mode with override_settings(SERVER_MODE="not-test"): - d.save() - self.assertEqual(mock_notify_task.delay.call_count, 1, "notify_task should be run on creation of DocEvent") - self.assertEqual(mock_notify_task.delay.call_args, mock.call(event_id = d.pk)) - - mock_notify_task.reset_mock() - with override_settings(SERVER_MODE="not-test"): - d.save() - self.assertFalse(mock_notify_task.delay.called, "notify_task should not be run save of on existing DocEvent") - + notify_of_event(event) + self.assertTrue(mock_notify_task.delay.called, "notify_task should run for a DocEvent on a draft") mock_notify_task.reset_mock() - d = DocEventFactory.build(by=person, doc=doc) - d.skip_community_list_notification = True + + event.skip_community_list_notification = True # be careful overriding SERVER_MODE - we do it here because the method # under test does not make this call when in "test" mode with override_settings(SERVER_MODE="not-test"): - d.save() + notify_of_event(event) self.assertFalse(mock_notify_task.delay.called, "notify_task should not run when skip_community_list_notification is set") - d = DocEventFactory.build(by=person, doc=DocumentFactory(type_id="rfc")) + event = DocEventFactory.build(by=person, doc=DocumentFactory(type_id="rfc")) # be careful overriding SERVER_MODE - we do it here because the method # under test does not make this call when in "test" mode with override_settings(SERVER_MODE="not-test"): - d.save() + notify_of_event(event) self.assertFalse(mock_notify_task.delay.called, "notify_task should not run on a document with type 'rfc'") @mock.patch("ietf.utils.mail.send_mail_text") diff --git a/ietf/doc/expire.py b/ietf/doc/expire.py index 681ca963a3..63955d091a 100644 --- a/ietf/doc/expire.py +++ b/ietf/doc/expire.py @@ -3,6 +3,8 @@ # expiry of Internet-Drafts +import debug # pyflakes:ignore + from django.conf import settings from django.utils import timezone @@ -11,12 +13,12 @@ from typing import List, Optional # pyflakes:ignore +from ietf.doc.utils import new_state_change_event, update_action_holders from ietf.utils import log from ietf.utils.mail import send_mail -from ietf.doc.models import Document, DocEvent, State, IESG_SUBSTATE_TAGS +from ietf.doc.models import Document, DocEvent, State, StateDocEvent from ietf.person.models import Person from ietf.meeting.models import Meeting -from ietf.doc.utils import add_state_change_event, update_action_holders from ietf.mailtrigger.utils import gather_address_lists from ietf.utils.timezone import date_today, datetime_today, DEADLINE_TZINFO @@ -148,10 +150,17 @@ def move_file(f): pass else: raise + + def remove_ftp_copy(f): + mark = Path(settings.FTP_DIR) / "internet-drafts" / f + if mark.exists(): + mark.unlink() + src_dir = Path(settings.INTERNET_DRAFT_PATH) for file in src_dir.glob("%s-%s.*" % (doc.name, rev)): move_file(str(file.name)) + remove_ftp_copy(str(file.name)) def expire_draft(doc): # clean up files @@ -161,24 +170,11 @@ def expire_draft(doc): events = [] - # change the state - if doc.latest_event(type='started_iesg_process'): - new_state = State.objects.get(used=True, type="draft-iesg", slug="dead") - prev_state = doc.get_state(new_state.type_id) - prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS) - if new_state != prev_state: - doc.set_state(new_state) - doc.tags.remove(*prev_tags) - e = add_state_change_event(doc, system, prev_state, new_state, prev_tags=prev_tags, new_tags=[]) - if e: - events.append(e) - e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[]) - if e: - events.append(e) - events.append(DocEvent.objects.create(doc=doc, rev=doc.rev, by=system, type="expired_document", desc="Document has expired")) + prev_draft_state=doc.get_state("draft") doc.set_state(State.objects.get(used=True, type="draft", slug="expired")) + events.append(update_action_holders(doc, prev_draft_state, doc.get_state("draft"),[],[])) doc.save_with_history(events) def clean_up_draft_files(): @@ -238,3 +234,42 @@ def move_file_to(subdir): except Document.DoesNotExist: # All uses of this past 2014 seem related to major system failures. move_file_to("unknown_ids") + + +def repair_dead_on_expire(): + by = Person.objects.get(name="(System)") + id_exists = State.objects.get(type="draft-iesg", slug="idexists") + dead = State.objects.get(type="draft-iesg", slug="dead") + dead_drafts = Document.objects.filter( + states__type="draft-iesg", states__slug="dead", type_id="draft" + ) + for d in dead_drafts: + dead_event = d.latest_event( + StateDocEvent, state_type="draft-iesg", state__slug="dead" + ) + if dead_event is not None: + if d.docevent_set.filter(type="expired_document").exists(): + closest_expiry = min( + [ + abs(e.time - dead_event.time) + for e in d.docevent_set.filter(type="expired_document") + ] + ) + if closest_expiry.total_seconds() < 60: + d.set_state(id_exists) + events = [] + e = DocEvent( + doc=d, + rev=d.rev, + type="added_comment", + by=by, + desc="IESG Dead state was set due only to document expiry - changing IESG state to ID-Exists", + ) + e.skip_community_list_notification = True + e.save() + events.append(e) + e = new_state_change_event(d, by, dead, id_exists) + e.skip_community_list_notification = True + e.save() + events.append(e) + d.save_with_history(events) diff --git a/ietf/doc/tasks.py b/ietf/doc/tasks.py index f1de459dd8..b7f89e1f9b 100644 --- a/ietf/doc/tasks.py +++ b/ietf/doc/tasks.py @@ -18,6 +18,7 @@ in_draft_expire_freeze, get_expired_drafts, expirable_drafts, + repair_dead_on_expire, send_expire_notice_for_draft, expire_draft, clean_up_draft_files, @@ -61,6 +62,11 @@ def expire_ids_task(): raise +@shared_task +def repair_dead_on_expire_task(): + repair_dead_on_expire() + + @shared_task def notify_expirations_task(notify_days=14): for doc in get_soon_to_expire_drafts(notify_days): diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index 09a7b38999..84959625c9 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -19,10 +19,10 @@ import debug # pyflakes:ignore -from ietf.doc.expire import get_expired_drafts, send_expire_notice_for_draft, expire_draft -from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, WgDraftFactory, RgDraftFactory, DocEventFactory +from ietf.doc.expire import expirable_drafts, get_expired_drafts, repair_dead_on_expire, send_expire_notice_for_draft, expire_draft +from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, StateDocEventFactory, WgDraftFactory, RgDraftFactory, DocEventFactory from ietf.doc.models import ( Document, DocReminder, DocEvent, - ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, TelechatDocEvent, + ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, StateDocEvent, TelechatDocEvent, WriteupDocEvent, DocRelationshipName, IanaExpertDocEvent ) from ietf.doc.utils import get_tags_for_stream_id, create_ballot_if_not_open from ietf.doc.views_draft import AdoptDraftForm @@ -36,7 +36,7 @@ from ietf.utils.test_utils import login_testing_unauthorized from ietf.utils.mail import outbox, empty_outbox, get_payload_text from ietf.utils.test_utils import TestCase -from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO +from ietf.utils.timezone import date_today, datetime_today, datetime_from_date, DEADLINE_TZINFO class ChangeStateTests(TestCase): @@ -763,13 +763,16 @@ def test_expire_drafts(self): txt = "%s-%s.txt" % (draft.name, draft.rev) self.write_draft_file(txt, 5000) + self.assertFalse(expirable_drafts(Document.objects.filter(pk=draft.pk)).exists()) + draft.set_state(State.objects.get(used=True, type="draft-iesg", slug="idexists")) + self.assertTrue(expirable_drafts(Document.objects.filter(pk=draft.pk)).exists()) expire_draft(draft) draft = Document.objects.get(name=draft.name) self.assertEqual(draft.get_state_slug(), "expired") - self.assertEqual(draft.get_state_slug("draft-iesg"), "dead") + self.assertEqual(draft.get_state_slug("draft-iesg"), "idexists") self.assertTrue(draft.latest_event(type="expired_document")) - self.assertCountEqual(draft.action_holders.all(), []) + self.assertEqual(draft.action_holders.count(), 0) self.assertIn('Removed all action holders', draft.latest_event(type='changed_action_holders').desc) self.assertTrue(not os.path.exists(os.path.join(settings.INTERNET_DRAFT_PATH, txt))) self.assertTrue(os.path.exists(os.path.join(settings.INTERNET_DRAFT_ARCHIVE_DIR, txt))) @@ -842,6 +845,77 @@ def test_clean_up_draft_files(self): self.assertTrue(not os.path.exists(os.path.join(settings.INTERNET_DRAFT_PATH, txt))) self.assertTrue(os.path.exists(os.path.join(settings.INTERNET_DRAFT_ARCHIVE_DIR, txt))) + @mock.patch("ietf.community.signals.notify_of_event") + def test_repair_dead_on_expire(self, mock_notify): + + # Create a draft in iesg idexists - ensure it doesn't get new docevents. + # Create a draft in iesg dead with no expires within the window - ensure it doesn't get new docevents and its state doesn't change. + # Create a draft in iesg dead with an expiry in the window - ensure it gets the right doc events, iesg state changes, draft state doesn't change. + last_year = datetime_today() - datetime.timedelta(days=365) + + not_dead = WgDraftFactory(name="draft-not-dead") + not_dead_event_count = not_dead.docevent_set.count() + + dead_not_from_expires = WgDraftFactory(name="draft-dead-not-from-expiring") + dead_not_from_expires.set_state( + State.objects.get(type="draft-iesg", slug="dead") + ) + StateDocEventFactory( + doc=dead_not_from_expires, state=("draft-iesg", "dead"), time=last_year + ) + DocEventFactory( + doc=dead_not_from_expires, + type="expired_document", + time=last_year + datetime.timedelta(days=1), + ) + dead_not_from_expires_event_count = dead_not_from_expires.docevent_set.count() + + dead_from_expires = [] + dead_from_expires_event_count = dict() + for delta in [-5, 5]: + d = WgDraftFactory( + name=f"draft-dead-from-expiring-just-{'before' if delta<0 else 'after'}" + ) + d.set_state(State.objects.get(type="draft-iesg", slug="dead")) + StateDocEventFactory(doc=d, state=("draft-iesg", "dead"), time=last_year) + DocEventFactory( + doc=d, + type="expired_document", + time=last_year + datetime.timedelta(seconds=delta), + ) + dead_from_expires.append(d) + dead_from_expires_event_count[d] = d.docevent_set.count() + + notified_during_factory_work = mock_notify.call_count + for call_args in mock_notify.call_args_list: + e = call_args.args[0] + self.assertTrue(isinstance(e,DocEvent)) + self.assertFalse(hasattr(e,"skip_community_list_notification")) + + repair_dead_on_expire() + + self.assertEqual(not_dead.docevent_set.count(), not_dead_event_count) + self.assertEqual( + dead_not_from_expires.docevent_set.count(), + dead_not_from_expires_event_count, + ) + for d in dead_from_expires: + self.assertEqual( + d.docevent_set.count(), dead_from_expires_event_count[d] + 2 + ) + self.assertIn( + "due only to document expiry", d.latest_event(type="added_comment").desc + ) + self.assertEqual( + d.latest_event(StateDocEvent).desc, + "IESG state changed to I-D Exists from Dead", + ) + self.assertEqual(mock_notify.call_count, 4+notified_during_factory_work) + for call_args in mock_notify.call_args_list[-4:]: + e = call_args.args[0] + self.assertTrue(isinstance(e,DocEvent)) + self.assertTrue(hasattr(e,"skip_community_list_notification")) + self.assertTrue(e.skip_community_list_notification) class ExpireLastCallTests(TestCase): def test_expire_last_call(self): diff --git a/ietf/doc/tests_tasks.py b/ietf/doc/tests_tasks.py index b75f58656b..135b52f604 100644 --- a/ietf/doc/tests_tasks.py +++ b/ietf/doc/tests_tasks.py @@ -1,4 +1,6 @@ # Copyright The IETF Trust 2024, All Rights Reserved + +import debug # pyflakes:ignore import datetime import mock @@ -19,6 +21,7 @@ generate_idnits2_rfcs_obsoleted_task, generate_idnits2_rfc_status_task, notify_expirations_task, + repair_dead_on_expire_task, ) class TaskTests(TestCase): @@ -96,6 +99,10 @@ def test_expire_last_calls_task(self, mock_get_expired, mock_expire): 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.repair_dead_on_expire") + def test_repair_dead_on_expire_task(self, mock_repair): + repair_dead_on_expire_task() + self.assertEqual(mock_repair.call_count, 1) class Idnits2SupportTests(TestCase): settings_temp_path_overrides = TestCase.settings_temp_path_overrides + ['DERIVED_DIR'] diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index b2bc0997b1..ff19dfbde6 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -491,8 +491,9 @@ def update_action_holders(doc, prev_state=None, new_state=None, prev_tags=None, Returns an event describing the change which should be passed to doc.save_with_history() - Only cares about draft-iesg state changes. Places where other state types are updated - may not call this method. If you add rules for updating action holders on other state + Only cares about draft-iesg state changes and draft expiration. + Places where other state types are updated may not call this method. + If you add rules for updating action holders on other state types, be sure this is called in the places that change that state. """ # Should not call this with different state types @@ -511,41 +512,50 @@ def update_action_holders(doc, prev_state=None, new_state=None, prev_tags=None, # Remember original list of action holders to later check if it changed prev_set = list(doc.action_holders.all()) - - # Update the action holders. To get this right for people with more - # than one relationship to the document, do removals first, then adds. - # Remove outdated action holders - iesg_state_changed = (prev_state != new_state) and (getattr(new_state, "type_id", None) == "draft-iesg") - if iesg_state_changed: - # Clear the action_holders list on a state change. This will reset the age of any that get added back. + + if new_state and new_state.type_id=="draft" and new_state.slug=="expired": doc.action_holders.clear() - if tags.removed("need-rev"): - # Removed the 'need-rev' tag - drop authors from the action holders list - DocumentActionHolder.objects.filter(document=doc, person__in=doc.authors()).delete() - elif tags.added("need-rev"): - # Remove the AD if we're asking for a new revision - DocumentActionHolder.objects.filter(document=doc, person=doc.ad).delete() - - # Add new action holders - if doc.ad: - # AD is an action holder unless specified otherwise for the new state - if iesg_state_changed and new_state.slug not in DocumentActionHolder.CLEAR_ACTION_HOLDERS_STATES: - doc.action_holders.add(doc.ad) - # If AD follow-up is needed, make sure they are an action holder - if tags.added("ad-f-up"): - doc.action_holders.add(doc.ad) - # Authors get the action if a revision is needed - if tags.added("need-rev"): - for auth in doc.authors(): - doc.action_holders.add(auth) - - # Now create an event if we changed the set - return add_action_holder_change_event( - doc, - Person.objects.get(name='(System)'), - prev_set, - reason='IESG state changed', - ) + return add_action_holder_change_event( + doc, + Person.objects.get(name='(System)'), + prev_set, + reason='draft expired', + ) + else: + # Update the action holders. To get this right for people with more + # than one relationship to the document, do removals first, then adds. + # Remove outdated action holders + iesg_state_changed = (prev_state != new_state) and (getattr(new_state, "type_id", None) == "draft-iesg") + if iesg_state_changed: + # Clear the action_holders list on a state change. This will reset the age of any that get added back. + doc.action_holders.clear() + if tags.removed("need-rev"): + # Removed the 'need-rev' tag - drop authors from the action holders list + DocumentActionHolder.objects.filter(document=doc, person__in=doc.authors()).delete() + elif tags.added("need-rev"): + # Remove the AD if we're asking for a new revision + DocumentActionHolder.objects.filter(document=doc, person=doc.ad).delete() + + # Add new action holders + if doc.ad: + # AD is an action holder unless specified otherwise for the new state + if iesg_state_changed and new_state.slug not in DocumentActionHolder.CLEAR_ACTION_HOLDERS_STATES: + doc.action_holders.add(doc.ad) + # If AD follow-up is needed, make sure they are an action holder + if tags.added("ad-f-up"): + doc.action_holders.add(doc.ad) + # Authors get the action if a revision is needed + if tags.added("need-rev"): + for auth in doc.authors(): + doc.action_holders.add(auth) + + # Now create an event if we changed the set + return add_action_holder_change_event( + doc, + Person.objects.get(name='(System)'), + prev_set, + reason='IESG state changed', + ) def update_documentauthors(doc, new_docauthors, by=None, basis=None): diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index d709aedd42..34104b2005 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -95,7 +95,8 @@ def change_state(request, name): and logging the change as a comment.""" doc = get_object_or_404(Document, name=name) - if (not doc.latest_event(type="started_iesg_process")) or doc.get_state_slug() == "expired": + # Steer ADs towards "Begin IESG Processing" + if doc.get_state_slug("draft-iesg")=="idexists" and not has_role(request.user,"Secretariat"): raise Http404 login = request.user.person diff --git a/ietf/settings.py b/ietf/settings.py index 7c3dc7fa16..4e678b3808 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -750,7 +750,6 @@ def skip_unreadable_post(record): STATUS_CHANGE_PATH = '/a/ietfdata/doc/status-change' AGENDA_PATH = '/a/www/www6s/proceedings/' MEETINGHOST_LOGO_PATH = AGENDA_PATH # put these in the same place as other proceedings files -IPR_DOCUMENT_PATH = '/a/www/ietf-ftp/ietf/IPR/' # Move drafts to this directory when they expire INTERNET_DRAFT_ARCHIVE_DIR = '/a/ietfdata/doc/draft/collection/draft-archive/' # The following directory contains copies of all drafts - it used to be diff --git a/ietf/static/css/ietf.scss b/ietf/static/css/ietf.scss index e2d5cb3959..f1f2b94a19 100644 --- a/ietf/static/css/ietf.scss +++ b/ietf/static/css/ietf.scss @@ -236,9 +236,13 @@ th, // Helper to constrain the size of the main logo .ietflogo { - width: 75%; + width: 100%; max-width: 300px; } +.ietflogo > img { + min-width: 100px; + width: 100%; +} // Make revision numbers pagination items fixed-width .revision-list { diff --git a/ietf/submit/utils.py b/ietf/submit/utils.py index e6cbcb12f7..5906785048 100644 --- a/ietf/submit/utils.py +++ b/ietf/submit/utils.py @@ -770,70 +770,6 @@ def save_files(form): log.log("saved file %s" % name) return file_name -def get_draft_meta(form, saved_files): - authors = [] - file_name = saved_files - - if form.cleaned_data['xml']: - # Some meta-information, such as the page-count, can only - # be retrieved from the generated text file. Provide a - # parsed draft object to get at that kind of information. - file_name['txt'] = os.path.join(settings.IDSUBMIT_STAGING_PATH, '%s-%s.txt' % (form.filename, form.revision)) - file_size = os.stat(file_name['txt']).st_size - with io.open(file_name['txt']) as txt_file: - form.parsed_draft = PlaintextDraft(txt_file.read(), txt_file.name) - else: - file_size = form.cleaned_data['txt'].size - - if form.authors: - authors = form.authors - else: - # If we don't have an xml file, try to extract the - # relevant information from the text file - for author in form.parsed_draft.get_author_list(): - full_name, first_name, middle_initial, last_name, name_suffix, email, country, company = author - - name = full_name.replace("\n", "").replace("\r", "").replace("<", "").replace(">", "").strip() - - if email: - try: - validate_email(email) - except ValidationError: - email = "" - - def turn_into_unicode(s): - if s is None: - return "" - - if isinstance(s, str): - return s - else: - try: - return s.decode("utf-8") - except UnicodeDecodeError: - try: - return s.decode("latin-1") - except UnicodeDecodeError: - return "" - - name = turn_into_unicode(name) - email = turn_into_unicode(email) - company = turn_into_unicode(company) - - authors.append({ - "name": name, - "email": email, - "affiliation": company, - "country": country - }) - - if form.abstract: - abstract = form.abstract - else: - abstract = form.parsed_draft.get_abstract() - - return authors, abstract, file_name, file_size - def get_submission(form): # See if there is a Submission in state waiting-for-draft @@ -1272,8 +1208,7 @@ def process_submission_xml(filename, revision): def _turn_into_unicode(s: Optional[Union[str, bytes]]): """Decode a possibly null string-like item as a string - Copied from ietf.submit.utils.get_draft_meta(), would be nice to - ditch this. + Would be nice to ditch this. """ if s is None: return "" @@ -1317,7 +1252,7 @@ def process_submission_text(filename, revision): if title: title = _normalize_title(title) - # Drops \r, \n, <, >. Based on get_draft_meta() behavior + # Translation taable drops \r, \n, <, >. trans_table = str.maketrans("", "", "\r\n<>") authors = [ { diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index f3228aa5ad..089141bde8 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -101,7 +101,7 @@
{% endblock %} diff --git a/ietf/utils/management/commands/loadrelated.py b/ietf/utils/management/commands/loadrelated.py index da9d00d5dc..d8ae19dc77 100644 --- a/ietf/utils/management/commands/loadrelated.py +++ b/ietf/utils/management/commands/loadrelated.py @@ -23,7 +23,7 @@ import debug # pyflakes:ignore -from ietf.community.models import notify_events +from ietf.community.signals import notify_of_events_receiver class Command(loaddata.Command): help = (""" @@ -62,7 +62,7 @@ def handle(self, *args, **options): # self.serialization_formats = serializers.get_public_serializer_formats() # - post_save.disconnect(notify_events) + post_save.disconnect(notify_of_events_receiver()) # connection = connections[self.using] self.fixture_count = 0 diff --git a/playwright/tests/meeting/agenda.spec.js b/playwright/tests/meeting/agenda.spec.js index 109e1b3b81..d31dbd5c2c 100644 --- a/playwright/tests/meeting/agenda.spec.js +++ b/playwright/tests/meeting/agenda.spec.js @@ -286,10 +286,17 @@ test.describe('past - desktop', () => { // No meeting materials yet warning badge await expect(eventButtons.locator('.no-meeting-materials')).toBeVisible() } - // Notepad button - const hedgeDocLink = `https://notes.ietf.org/notes-ietf-${meetingData.meeting.number}-${event.type === 'plenary' ? 'plenary' : event.acronym}` - await expect(eventButtons.locator(`#btn-lnk-${event.id}-note`)).toHaveAttribute('href', hedgeDocLink) - await expect(eventButtons.locator(`#btn-lnk-${event.id}-note > i.bi`)).toBeVisible() + if (event.name.toLowerCase().includes('hackathon')) { + // Hackathon Wiki button + const hackathonWikiLink = `https://wiki.ietf.org/meeting/${meetingData.meeting.number}/hackathon` + await expect(eventButtons.locator(`#btn-lnk-${event.id}-wiki`)).toHaveAttribute('href', hackathonWikiLink) + await expect(eventButtons.locator(`#btn-lnk-${event.id}-wiki > i.bi`)).toBeVisible() + } else { + // Notepad button + const hedgeDocLink = `https://notes.ietf.org/notes-ietf-${meetingData.meeting.number}-${event.type === 'plenary' ? 'plenary' : event.acronym}` + await expect(eventButtons.locator(`#btn-lnk-${event.id}-note`)).toHaveAttribute('href', hedgeDocLink) + await expect(eventButtons.locator(`#btn-lnk-${event.id}-note > i.bi`)).toBeVisible() + } // Chat logs await expect(eventButtons.locator(`#btn-lnk-${event.id}-logs`)).toHaveAttribute('href', event.links.chatArchive) await expect(eventButtons.locator(`#btn-lnk-${event.id}-logs > i.bi`)).toBeVisible() @@ -1162,10 +1169,17 @@ test.describe('future - desktop', () => { // No meeting materials yet warning badge await expect(eventButtons.locator('.no-meeting-materials')).toBeVisible() } - // Notepad button - const hedgeDocLink = `https://notes.ietf.org/notes-ietf-${meetingData.meeting.number}-${event.type === 'plenary' ? 'plenary' : event.acronym}` - await expect(eventButtons.locator(`#btn-lnk-${event.id}-note`)).toHaveAttribute('href', hedgeDocLink) - await expect(eventButtons.locator(`#btn-lnk-${event.id}-note > i.bi`)).toBeVisible() + if (event.name.toLowerCase().includes('hackathon')) { + // Hackathon Wiki button + const hackathonWikiLink = `https://wiki.ietf.org/meeting/${meetingData.meeting.number}/hackathon` + await expect(eventButtons.locator(`#btn-lnk-${event.id}-wiki`)).toHaveAttribute('href', hackathonWikiLink) + await expect(eventButtons.locator(`#btn-lnk-${event.id}-wiki > i.bi`)).toBeVisible() + } else { + // Notepad button + const hedgeDocLink = `https://notes.ietf.org/notes-ietf-${meetingData.meeting.number}-${event.type === 'plenary' ? 'plenary' : event.acronym}` + await expect(eventButtons.locator(`#btn-lnk-${event.id}-note`)).toHaveAttribute('href', hedgeDocLink) + await expect(eventButtons.locator(`#btn-lnk-${event.id}-note > i.bi`)).toBeVisible() + } // Chat room await expect(eventButtons.locator(`#btn-lnk-${event.id}-room`)).toHaveAttribute('href', event.links.chat) await expect(eventButtons.locator(`#btn-lnk-${event.id}-room > i.bi`)).toBeVisible() diff --git a/test/settings_local_test.py b/test/settings_local_test.py index 06d810e4af..7097f76459 100644 --- a/test/settings_local_test.py +++ b/test/settings_local_test.py @@ -5,6 +5,5 @@ SERVER_MODE = 'test' -IPR_DOCUMENT_PATH = '/home/ietf/adm/IPR/' SITE_ID = 1