diff --git a/docker/configs/settings_local.py b/docker/configs/settings_local.py index e357ce3f73..1d4e6916b9 100644 --- a/docker/configs/settings_local.py +++ b/docker/configs/settings_local.py @@ -105,3 +105,7 @@ "ietf.api.red_api" : ["devtoken", "redtoken"], # Not a real secret "ietf.api.views_rpc" : ["devtoken"], # Not a real secret } + +# Errata system api configuration +ERRATA_METADATA_NOTIFICATION_URL = "http://host.docker.internal:8808/api/rfc_metadata_update/" +ERRATA_METADATA_NOTIFICATION_API_KEY = "not a real secret" diff --git a/ietf/api/tests_views_rpc.py b/ietf/api/tests_views_rpc.py index 1fbb4c3f02..6a5a5c9b88 100644 --- a/ietf/api/tests_views_rpc.py +++ b/ietf/api/tests_views_rpc.py @@ -9,9 +9,10 @@ from django.db.models.functions import Coalesce from django.test.utils import override_settings from django.urls import reverse as urlreverse +import mock from ietf.blobdb.models import Blob -from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory +from ietf.doc.factories import IndividualDraftFactory, RfcFactory, WgDraftFactory, WgRfcFactory from ietf.doc.models import RelatedDocument, Document from ietf.group.factories import RoleFactory, GroupFactory from ietf.person.factories import PersonFactory @@ -77,7 +78,8 @@ def test_draftviewset_references(self): self.assertEqual(refs[0]["name"], draft_bar.name) @override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]}) - def test_notify_rfc_published(self): + @mock.patch("ietf.doc.tasks.signal_update_rfc_metadata_task.delay") + def test_notify_rfc_published(self, mock_task_delay): url = urlreverse("ietf.api.purple_api.notify_rfc_published") area = GroupFactory(type_id="area") rfc_group = GroupFactory(type_id="wg") @@ -90,6 +92,8 @@ def test_notify_rfc_published(self): ) rfc_stream_id = "ise" assert isinstance(draft, Document), "WgDraftFactory should generate a Document" + updates = RfcFactory.create_batch(2) + obsoletes = RfcFactory.create_batch(2) unused_rfc_number = ( Document.objects.filter(rfc_number__isnull=False).aggregate( unused_rfc_number=Max("rfc_number") + 1 @@ -120,8 +124,8 @@ def test_notify_rfc_published(self): "pages": draft.pages + 10, "std_level": "ps", "ad": rfc_ad.pk, - "obsoletes": [], - "updates": [], + "obsoletes": [o.rfc_number for o in obsoletes], + "updates": [o.rfc_number for o in updates], "subseries": [], } r = self.client.post(url, data=post_data, format="json") @@ -172,13 +176,25 @@ def test_notify_rfc_published(self): self.assertEqual(rfc.pages, draft.pages + 10) self.assertEqual(rfc.std_level_id, "ps") self.assertEqual(rfc.ad, rfc_ad) - self.assertEqual(rfc.related_that_doc("obs"), []) - self.assertEqual(rfc.related_that_doc("updates"), []) + self.assertEqual(set(rfc.related_that_doc("obs")), set([o for o in obsoletes])) + self.assertEqual( + set(rfc.related_that_doc("updates")), set([o for o in updates]) + ) self.assertEqual(rfc.part_of(), []) self.assertEqual(draft.get_state().slug, "rfc") # todo test non-empty relationships # todo test references (when updating that is part of the handling) + self.assertTrue(mock_task_delay.called) + mock_args, mock_kwargs = mock_task_delay.call_args + self.assertIn("rfc_number_list", mock_kwargs) + expected_rfc_number_list = [rfc.rfc_number] + expected_rfc_number_list.extend( + [d.rfc_number for d in updates + obsoletes] + ) + expected_rfc_number_list = sorted(set(expected_rfc_number_list)) + self.assertEqual(mock_kwargs["rfc_number_list"], expected_rfc_number_list) + @override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]}) def test_upload_rfc_files(self): def _valid_post_data(): diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index 2bf16480f2..9273590b28 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -38,6 +38,7 @@ from ietf.doc.models import Document, DocHistory, RfcAuthor from ietf.doc.serializers import RfcAuthorSerializer from ietf.doc.storage_utils import remove_from_storage, store_file, exists_in_storage +from ietf.doc.tasks import signal_update_rfc_metadata_task from ietf.person.models import Email, Person @@ -362,7 +363,7 @@ def post(self, request): serializer.is_valid(raise_exception=True) # Create RFC try: - serializer.save() + rfc = serializer.save() except IntegrityError as err: if Document.objects.filter( rfc_number=serializer.validated_data["rfc_number"] @@ -375,6 +376,12 @@ def post(self, request): f"Unable to publish: {err}", code="unknown-integrity-error", ) + rfc_number_list = [rfc.rfc_number] + rfc_number_list.extend( + [d.rfc_number for d in rfc.related_that_doc(("updates", "obs"))] + ) + rfc_number_list = sorted(set(rfc_number_list)) + signal_update_rfc_metadata_task.delay(rfc_number_list=rfc_number_list) return Response(NotificationAckSerializer().data) diff --git a/ietf/doc/tasks.py b/ietf/doc/tasks.py index b463b9cecf..90f4c80af5 100644 --- a/ietf/doc/tasks.py +++ b/ietf/doc/tasks.py @@ -35,6 +35,7 @@ investigate_fragment, ) from .utils_bofreq import fixup_bofreq_timestamps +from .utils_errata import signal_update_rfc_metadata @shared_task @@ -155,3 +156,7 @@ def rebuild_reference_relations_task(doc_names: list[str]): @shared_task def fixup_bofreq_timestamps_task(): # pragma: nocover fixup_bofreq_timestamps() + +@shared_task +def signal_update_rfc_metadata_task(rfc_number_list=()): + signal_update_rfc_metadata(rfc_number_list) diff --git a/ietf/doc/utils_errata.py b/ietf/doc/utils_errata.py new file mode 100644 index 0000000000..539262151f --- /dev/null +++ b/ietf/doc/utils_errata.py @@ -0,0 +1,35 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +import requests + +from django.conf import settings + +from ietf.utils.log import log + + +def signal_update_rfc_metadata(rfc_number_list=()): + key = getattr(settings, "ERRATA_METADATA_NOTIFICATION_API_KEY", None) + if key is not None: + headers = {"X-Api-Key": settings.ERRATA_METADATA_NOTIFICATION_API_KEY} + post_dict = { + "rfc_number_list": list(rfc_number_list), + } + try: + response = requests.post( + settings.ERRATA_METADATA_NOTIFICATION_URL, + headers=headers, + json=post_dict, + timeout=settings.DEFAULT_REQUESTS_TIMEOUT, + ) + except requests.Timeout as e: + log( + f"POST request timed out for {settings.ERRATA_METADATA_NOTIFICATION_URL} ]: {e}" + ) + # raise RuntimeError(f'POST request timed out for {settings.ERRATA_METADATA_NOTIFICATION_URL}') from e + return + if response.status_code != 200: + log( + f"POST request failed for {settings.ERRATA_METADATA_NOTIFICATION_URL} ]: {response.status_code} {response.text}" + ) + else: + log("No API key configured for errata metadata notification, skipping") diff --git a/ietf/settings.py b/ietf/settings.py index 565e8825a9..71b110d762 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1368,6 +1368,11 @@ def skip_unreadable_post(record): MEETECHO_AUDIO_STREAM_URL = "https://mp3.conf.meetecho.com/ietf{session.meeting.number}/{session.pk}.m3u" MEETECHO_SESSION_RECORDING_URL = "https://meetecho-player.ietf.org/playout/?session={session_label}" +# Errata system api configuration +# settings should provide +# ERRATA_METADATA_NOTIFICATION_URL +# ERRATA_METADATA_NOTIFICATION_API_KEY + # Put the production SECRET_KEY in settings_local.py, and also any other # sensitive or site-specific changes. DO NOT commit settings_local.py to svn. from ietf.settings_local import * # pyflakes:ignore pylint: disable=wildcard-import