diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff84408187..a89bac46e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -418,17 +418,14 @@ jobs: echo "DEPLOY_NAMESPACE=$(node cli.js --branch ${{ github.ref_name }})" >> "$GITHUB_ENV" - name: Deploy to dev - uses: the-actions-org/workflow-dispatch@v4 + uses: ietf-tools/workflow-dispatch-action@v1 with: workflow: deploy-dev.yml repo: ietf-tools/infra-k8s ref: main token: ${{ secrets.GH_INFRA_K8S_TOKEN }} inputs: '{ "app":"datatracker", "appVersion":"${{ env.PKG_VERSION }}", "remoteRef":"${{ github.sha }}", "namespace":"${{ env.DEPLOY_NAMESPACE }}", "disableDailyDbRefresh":${{ inputs.devNoDbRefresh }} }' - wait-for-completion: true - wait-for-completion-timeout: 60m - wait-for-completion-interval: 30s - display-workflow-run-url: false + waitForCompletionTimeout: 60m # ----------------------------------------------------------------- # STAGING @@ -445,30 +442,24 @@ jobs: steps: - name: Refresh Staging DB - uses: the-actions-org/workflow-dispatch@v4 + uses: ietf-tools/workflow-dispatch-action@v1 with: workflow: deploy-db.yml repo: ietf-tools/infra-k8s ref: main token: ${{ secrets.GH_INFRA_K8S_TOKEN }} inputs: '{ "environment":"${{ secrets.GHA_K8S_CLUSTER }}", "app":"datatracker", "manifest":"postgres", "forceRecreate":true, "restoreToLastFullSnapshot":true, "waitClusterReady":true }' - wait-for-completion: true - wait-for-completion-timeout: 120m - wait-for-completion-interval: 20s - display-workflow-run-url: false + waitForCompletionTimeout: 120m - name: Deploy to staging - uses: the-actions-org/workflow-dispatch@v4 + uses: ietf-tools/workflow-dispatch-action@v1 with: workflow: deploy.yml repo: ietf-tools/infra-k8s ref: main token: ${{ secrets.GH_INFRA_K8S_TOKEN }} inputs: '{ "environment":"${{ secrets.GHA_K8S_CLUSTER }}", "app":"datatracker", "appVersion":"${{ env.PKG_VERSION }}", "remoteRef":"${{ github.sha }}" }' - wait-for-completion: true - wait-for-completion-timeout: 30m - wait-for-completion-interval: 30s - display-workflow-run-url: false + waitForCompletionTimeout: 30m # ----------------------------------------------------------------- # PROD @@ -485,14 +476,11 @@ jobs: steps: - name: Deploy to production - uses: the-actions-org/workflow-dispatch@v4 + uses: ietf-tools/workflow-dispatch-action@v1 with: workflow: deploy.yml repo: ietf-tools/infra-k8s ref: main token: ${{ secrets.GH_INFRA_K8S_TOKEN }} inputs: '{ "environment":"${{ secrets.GHA_K8S_CLUSTER }}", "app":"datatracker", "appVersion":"${{ env.PKG_VERSION }}", "remoteRef":"${{ github.sha }}" }' - wait-for-completion: true - wait-for-completion-timeout: 30m - wait-for-completion-interval: 30s - display-workflow-run-url: false + waitForCompletionTimeout: 30m diff --git a/.vscode/settings.json b/.vscode/settings.json index b323cd02f7..ad6b0adc84 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,5 +57,8 @@ "python.testing.pytestEnabled": false, "python.testing.unittestEnabled": false, "python.linting.enabled": true, - "python.terminal.shellIntegration.enabled": false + "python.terminal.shellIntegration.enabled": false, + "vs-kubernetes": { + "disable-linters": ["resource-limits"] + } } diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile index e57fecd5f2..1e44f47761 100644 --- a/dev/build/Dockerfile +++ b/dev/build/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/ietf-tools/datatracker-app-base:20260410T1557 +FROM ghcr.io/ietf-tools/datatracker-app-base:20260527T1529 LABEL maintainer="IETF Tools Team " ENV DEBIAN_FRONTEND=noninteractive diff --git a/dev/build/TARGET_BASE b/dev/build/TARGET_BASE index f430037c09..0284151019 100644 --- a/dev/build/TARGET_BASE +++ b/dev/build/TARGET_BASE @@ -1 +1 @@ -20260410T1557 +20260527T1529 diff --git a/ietf/api/serializers_rpc.py b/ietf/api/serializers_rpc.py index d888de4586..0e45ee3b39 100644 --- a/ietf/api/serializers_rpc.py +++ b/ietf/api/serializers_rpc.py @@ -8,7 +8,7 @@ from django.utils import timezone from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers +from rest_framework import fields, serializers from ietf.doc.expire import move_draft_files_to_archive from ietf.doc.models import ( @@ -265,6 +265,23 @@ def __init__(self, **kwargs): super().__init__(regex, **kwargs) +class RfcGroupRelatedField(serializers.SlugRelatedField): + """SlugRelatedField that translates None / "" to the acronym "none" """ + + def __init__(self, **kwargs): + super().__init__( + slug_field="acronym", + queryset=Group.objects.all(), + allow_null=True, + required=False, + ) + + def run_validation(self, data=fields.empty): + # Use the Group with acronym "none" when group is not specified + if data is fields.empty or data is None or data == "": + data = "none" + return super().run_validation(data) + class RfcPubSerializer(serializers.ModelSerializer): """Write-only serializer for RFC publication""" @@ -279,9 +296,7 @@ class RfcPubSerializer(serializers.ModelSerializer): # fields on the RFC Document that need tweaking from ModelSerializer defaults rfc_number = serializers.IntegerField(min_value=1, required=True) - group = serializers.SlugRelatedField( - slug_field="acronym", queryset=Group.objects.all(), required=False - ) + group = RfcGroupRelatedField() stream = serializers.PrimaryKeyRelatedField( queryset=StreamName.objects.filter(used=True) ) @@ -556,6 +571,18 @@ class EditableRfcSerializer(serializers.ModelSerializer): child=SubseriesNameField(required=False), write_only=True, ) + updates = serializers.ListField( + child=serializers.IntegerField(), + required=False, + write_only=True, + help_text="List of RFC numbers this document updates." + ) + obsoletes = serializers.ListField( + child=serializers.IntegerField(), + required=False, + write_only=True, + help_text="List of RFC numbers this document obsoletes." + ) class Meta: model = Document @@ -569,7 +596,27 @@ class Meta: "std_level", "subseries", "keywords", + "updates", + "obsoletes", + ] + + def _validate_rfc_number_list(self, field_name, rfc_numbers): + """Raise ValidationError if any RFC numbers in the list don't exist.""" + unknown = [ + n for n in rfc_numbers + if not Document.objects.filter(rfc_number=n, type_id="rfc").exists() ] + if unknown: + raise serializers.ValidationError( + {field_name: [f"Unknown RFC number: {n}" for n in unknown]} + ) + return rfc_numbers + + def validate_updates(self, value): + return self._validate_rfc_number_list("updates", value) + + def validate_obsoletes(self, value): + return self._validate_rfc_number_list("obsoletes", value) def create(self, validated_data): raise RuntimeError("Cannot create with this serializer") @@ -587,6 +634,8 @@ def update(self, instance, validated_data): published = validated_data.pop("published", omitted) subseries = validated_data.pop("subseries", omitted) authors_data = validated_data.pop("rfcauthor_set", omitted) + updates = validated_data.pop("updates", omitted) + obsoletes = validated_data.pop("obsoletes", omitted) # Transaction to clean up if something fails with transaction.atomic(): @@ -658,6 +707,24 @@ def update(self, instance, validated_data): ) ) ) + if updates is not omitted: + RelatedDocument.objects.filter( + source=rfc, relationship_id="updates" + ).exclude(target__rfc_number__in=updates).delete() + for rfc_num in updates: + target = Document.objects.get(rfc_number=rfc_num, type_id="rfc") + RelatedDocument.objects.get_or_create( + source=rfc, relationship_id="updates", target=target + ) + if obsoletes is not omitted: + RelatedDocument.objects.filter( + source=rfc, relationship_id="obs" + ).exclude(target__rfc_number__in=obsoletes).delete() + for rfc_num in obsoletes: + target = Document.objects.get(rfc_number=rfc_num, type_id="rfc") + RelatedDocument.objects.get_or_create( + source=rfc, relationship_id="obs", target=target + ) # update subseries relations if subseries is not omitted: diff --git a/ietf/api/tests_serializers_rpc.py b/ietf/api/tests_serializers_rpc.py index 167ffcd3ee..5151f337d5 100644 --- a/ietf/api/tests_serializers_rpc.py +++ b/ietf/api/tests_serializers_rpc.py @@ -215,3 +215,27 @@ def test_partial_update(self, mock_trigger_red_task, mock_update_searchindex_tas mock_update_searchindex_task.delay.call_args, mock.call(rfc.rfc_number), ) + + def test_unknown_rfc_number_rejected(self): + """Unknown RFC numbers in updates/obsoletes should cause validation failure.""" + from django.db.models import Max + + rfc = WgRfcFactory() + unknown_rfc_number = ( + Document.objects.filter(rfc_number__isnull=False).aggregate( + m=Max("rfc_number") + 1 + )["m"] + or 10000 + ) + + for field in ("updates", "obsoletes"): + serializer = EditableRfcSerializer( + partial=True, + instance=rfc, + data={field: [unknown_rfc_number]}, + ) + self.assertFalse( + serializer.is_valid(), + msg=f"{field} with unknown RFC number should be invalid", + ) + self.assertIn(field, serializer.errors) diff --git a/ietf/api/tests_views_rpc.py b/ietf/api/tests_views_rpc.py index c836cdc2c0..6d147c00b0 100644 --- a/ietf/api/tests_views_rpc.py +++ b/ietf/api/tests_views_rpc.py @@ -477,3 +477,91 @@ def test_destination_helper_mixin_blob_destination(self): DestinationHelperMixin().blob_destination(filename), f"notprepped/{filename}", ) + + @override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]}) + @mock.patch("ietf.api.views_rpc.process_rpc_queue_task.delay") + def test_process_rpc_queue(self, mock_task_delay): + url = urlreverse("ietf.api.purple_api.process_rpc_queue") + queue_entries = [ + { + "id": 9850, + "name": "draft-ietf-netmod-system-config", + "title": "System-defined Configuration", + "draft_url": "http://localhost:8000/doc/draft-ietf-netmod-system-config-20", + "disposition": "in_progress", + "external_deadline": None, + "labels": [], + "cluster": None, + "assignment_set": [ + { + "id": 434, + "rfc_to_be": 9850, + "role": "first_editor", + "state": "in_progress", + } + ], + "actionholder_set": [], + "pending_activities": [], + "rfc_number": None, + "pages": 33, + "enqueued_at": "2026-01-26T12:00:00Z", + "final_approval": [], + "iana_status": { + "slug": "completed", + "name": "completed", + "desc": "IANA has completed actions in draft", + }, + "blocking_reasons": [], + "authors": [{"titlepage_name": "Q. Ma", "is_editor": True}], + "approval_log_message": [], + "stream": "ietf", + "group": "netmod", + "group_name": "Network Modeling", + "std_level": "ps", + "references": [], + "rev": "20", + } + ] + queue_data = {"data": queue_entries} + + # no credentials + response = self.client.post( + url, data=queue_data, content_type="application/json" + ) + self.assertEqual(response.status_code, 403) + mock_task_delay.assert_not_called() + + # invalid token + response = self.client.post( + url, + data=queue_data, + content_type="application/json", + headers={"X-Api-Key": "invalid-token"}, + ) + self.assertEqual(response.status_code, 403) + mock_task_delay.assert_not_called() + + # valid token, wrong method + response = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(response.status_code, 405) + mock_task_delay.assert_not_called() + + # valid token, missing "data" field + response = self.client.post( + url, + data={}, + content_type="application/json", + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(response.status_code, 400) + mock_task_delay.assert_not_called() + + # valid token, POST with data + response = self.client.post( + url, + data=queue_data, + content_type="application/json", + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(response.status_code, 202) + mock_task_delay.assert_called_once_with(queue_entries) diff --git a/ietf/api/urls_rpc.py b/ietf/api/urls_rpc.py index 8555610dc3..07f2cf8751 100644 --- a/ietf/api/urls_rpc.py +++ b/ietf/api/urls_rpc.py @@ -36,6 +36,11 @@ name="ietf.api.purple_api.refresh_rfc_index", ), path(r"subject//person/", views_rpc.SubjectPersonView.as_view()), + path( + r"queue/process/", + views_rpc.ProcessRpcQueueView.as_view(), + name="ietf.api.purple_api.process_rpc_queue", + ), ] # add routers at the end so individual routes can steal parts of their address diff --git a/ietf/api/views_rpc.py b/ietf/api/views_rpc.py index e9c17b8a12..83d0abefb1 100644 --- a/ietf/api/views_rpc.py +++ b/ietf/api/views_rpc.py @@ -48,6 +48,7 @@ ) from ietf.person.models import Email, Person from ietf.sync.rfcindex import mark_rfcindex_as_dirty +from ietf.sync.tasks import process_rpc_queue_task class Conflict(APIException): @@ -576,3 +577,24 @@ class RfcIndexView(APIView): def post(self, request): mark_rfcindex_as_dirty() return Response(status=202) + + +class RpcQueueDataSerializer(serializers.Serializer): + data = serializers.JSONField() + + +class ProcessRpcQueueView(APIView): + api_key_endpoint = "ietf.api.views_rpc" + + @extend_schema( + operation_id="process_rpc_queue", + summary="Process the provided RPC queue", + description="Schedules parsing the provided queue to update documents with change dqueue data", + responses={202: None}, + request=RpcQueueDataSerializer, + ) + def post(self, request): + serializer = RpcQueueDataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + process_rpc_queue_task.delay(serializer.validated_data["data"]) + return Response(status=202) diff --git a/ietf/doc/admin.py b/ietf/doc/admin.py index 757d3da9f9..86f5ac5fda 100644 --- a/ietf/doc/admin.py +++ b/ietf/doc/admin.py @@ -15,7 +15,7 @@ AddedMessageEvent, SubmissionDocEvent, DeletedEvent, EditedAuthorsDocEvent, DocumentURL, ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder, BofreqEditorDocEvent, BofreqResponsibleDocEvent, StoredObject, RfcAuthor, - EditedRfcAuthorsDocEvent) + EditedRfcAuthorsDocEvent, RpcAssignmentDocEvent) from ietf.utils.admin import SaferTabularInline from ietf.utils.validators import validate_external_resource_value @@ -229,6 +229,10 @@ class SubmissionDocEventAdmin(DocEventAdmin): raw_id_fields = DocEventAdmin.raw_id_fields + ["submission"] admin.site.register(SubmissionDocEvent, SubmissionDocEventAdmin) +class RpcAssignmentDocEventAdmin(DocEventAdmin): + search_fields = DocEventAdmin.search_fields + ["assignments"] +admin.site.register(RpcAssignmentDocEvent, RpcAssignmentDocEventAdmin) + class DocumentUrlAdmin(admin.ModelAdmin): list_display = ['id', 'doc', 'tag', 'url', 'desc', ] search_fields = ['doc__name', 'url', ] diff --git a/ietf/doc/feeds.py b/ietf/doc/feeds.py index 0269906fcf..7472b14c18 100644 --- a/ietf/doc/feeds.py +++ b/ietf/doc/feeds.py @@ -234,14 +234,6 @@ def item_extra_kwargs(self, item): "is_format_of": self.item_link(item), } ) - if item.rfc_number not in [571, 587]: - media_contents.append( - { - "url": f"https://www.rfc-editor.org/rfc/pdfrfc/{item.name}.txt.pdf", - "media_type": "application/pdf", - "is_format_of": self.item_link(item), - } - ) else: media_contents.append( { diff --git a/ietf/doc/migrations/0034_alter_dochistory_keywords_alter_document_keywords.py b/ietf/doc/migrations/0034_alter_dochistory_keywords_alter_document_keywords.py new file mode 100644 index 0000000000..2b89b67e88 --- /dev/null +++ b/ietf/doc/migrations/0034_alter_dochistory_keywords_alter_document_keywords.py @@ -0,0 +1,33 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from django.db import migrations, models +import ietf.doc.models + + +class Migration(migrations.Migration): + dependencies = [ + ("doc", "0033_dochistory_keywords_document_keywords"), + ] + + operations = [ + migrations.AlterField( + model_name="dochistory", + name="keywords", + field=models.JSONField( + blank=True, + default=list, + max_length=1000, + validators=[ietf.doc.models.validate_doc_keywords], + ), + ), + migrations.AlterField( + model_name="document", + name="keywords", + field=models.JSONField( + blank=True, + default=list, + max_length=1000, + validators=[ietf.doc.models.validate_doc_keywords], + ), + ), + ] diff --git a/ietf/doc/migrations/0035_add_rpc_queue_draft_rfceditor_states.py b/ietf/doc/migrations/0035_add_rpc_queue_draft_rfceditor_states.py new file mode 100644 index 0000000000..9805970ef0 --- /dev/null +++ b/ietf/doc/migrations/0035_add_rpc_queue_draft_rfceditor_states.py @@ -0,0 +1,31 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from django.db import migrations + + +def forward(apps, schema_editor): + State = apps.get_model("doc", "State") + for slug, name in [("in_progress", "In Progress"), ("blocked", "Blocked")]: + State.objects.get_or_create( + type_id="draft-rfceditor", + slug=slug, + defaults={"name": name, "used": True, "desc": "", "order": 0}, + ) + + +def reverse(apps, schema_editor): + State = apps.get_model("doc", "State") + Document = apps.get_model("doc", "Document") + for slug in ("in_progress", "blocked"): + assert not Document.objects.filter( + states__type="draft-rfceditor", states__slug=slug + ).exists() + State.objects.filter(type_id="draft-rfceditor", slug=slug).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("doc", "0034_alter_dochistory_keywords_alter_document_keywords"), + ] + + operations = [migrations.RunPython(forward, reverse)] diff --git a/ietf/doc/migrations/0036_alter_docevent_type.py b/ietf/doc/migrations/0036_alter_docevent_type.py new file mode 100644 index 0000000000..1cc11d4ee9 --- /dev/null +++ b/ietf/doc/migrations/0036_alter_docevent_type.py @@ -0,0 +1,92 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("doc", "0035_add_rpc_queue_draft_rfceditor_states"), + ] + + operations = [ + migrations.AlterField( + model_name="docevent", + name="type", + field=models.CharField( + choices=[ + ("new_revision", "Added new revision"), + ("new_submission", "Uploaded new revision"), + ("changed_document", "Changed document metadata"), + ("added_comment", "Added comment"), + ("added_message", "Added message"), + ("edited_authors", "Edited the documents author list"), + ("deleted", "Deleted document"), + ("changed_state", "Changed state"), + ("changed_stream", "Changed document stream"), + ("expired_document", "Expired document"), + ("extended_expiry", "Extended expiry of document"), + ("requested_resurrect", "Requested resurrect"), + ("completed_resurrect", "Completed resurrect"), + ("changed_consensus", "Changed consensus"), + ("published_rfc", "Published RFC"), + ( + "added_suggested_replaces", + "Added suggested replacement relationships", + ), + ( + "reviewed_suggested_replaces", + "Reviewed suggested replacement relationships", + ), + ("changed_action_holders", "Changed action holders for document"), + ("changed_group", "Changed group"), + ("changed_protocol_writeup", "Changed protocol writeup"), + ("changed_charter_milestone", "Changed charter milestone"), + ("initial_review", "Set initial review time"), + ("changed_review_announcement", "Changed WG Review text"), + ("changed_action_announcement", "Changed WG Action text"), + ("started_iesg_process", "Started IESG process on document"), + ("created_ballot", "Created ballot"), + ("closed_ballot", "Closed ballot"), + ("sent_ballot_announcement", "Sent ballot announcement"), + ("changed_ballot_position", "Changed ballot position"), + ("changed_ballot_approval_text", "Changed ballot approval text"), + ("changed_ballot_writeup_text", "Changed ballot writeup text"), + ("changed_rfc_editor_note_text", "Changed RFC Editor Note text"), + ("changed_last_call_text", "Changed last call text"), + ("requested_last_call", "Requested last call"), + ("sent_last_call", "Sent last call"), + ("scheduled_for_telechat", "Scheduled for telechat"), + ("iesg_approved", "IESG approved document (no problem)"), + ("iesg_disapproved", "IESG disapproved document (do not publish)"), + ("approved_in_minute", "Approved in minute"), + ("iana_review", "IANA review comment"), + ("rfc_in_iana_registry", "RFC is in IANA registry"), + ( + "rfc_editor_received_announcement", + "Announcement was received by RFC Editor", + ), + ("requested_publication", "Publication at RFC Editor requested"), + ( + "sync_from_rfc_editor", + "Received updated information from RFC Editor", + ), + ("changed_rpc_assignments", "Changed RPC queue assignments"), + ("requested_review", "Requested review"), + ("assigned_review_request", "Assigned review request"), + ("closed_review_request", "Closed review request"), + ("closed_review_assignment", "Closed review assignment"), + ("downref_approved", "Downref approved"), + ("posted_related_ipr", "Posted related IPR"), + ("removed_related_ipr", "Removed related IPR"), + ( + "removed_objfalse_related_ipr", + "Removed Objectively False related IPR", + ), + ("changed_editors", "Changed BOF Request editors"), + ("published_statement", "Published statement"), + ("approved_slides", "Slides approved"), + ], + max_length=50, + ), + ), + ] diff --git a/ietf/doc/migrations/0037_rpcassignmentdocevent.py b/ietf/doc/migrations/0037_rpcassignmentdocevent.py new file mode 100644 index 0000000000..648376e118 --- /dev/null +++ b/ietf/doc/migrations/0037_rpcassignmentdocevent.py @@ -0,0 +1,31 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("doc", "0036_alter_docevent_type"), + ] + + operations = [ + migrations.CreateModel( + name="RpcAssignmentDocEvent", + fields=[ + ( + "docevent_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="doc.docevent", + ), + ), + ("assignments", models.TextField(blank=True)), + ], + bases=("doc.docevent",), + ), + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index cc79b73831..156bac4e77 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -157,6 +157,7 @@ class DocumentInfo(models.Model): default=list, max_length=1000, validators=[validate_doc_keywords], + blank=True, ) @property @@ -1545,6 +1546,7 @@ class DocReminder(models.Model): ("rfc_editor_received_announcement", "Announcement was received by RFC Editor"), ("requested_publication", "Publication at RFC Editor requested"), ("sync_from_rfc_editor", "Received updated information from RFC Editor"), + ("changed_rpc_assignments", "Changed RPC queue assignments"), # review ("requested_review", "Requested review"), @@ -1610,6 +1612,9 @@ class StateDocEvent(DocEvent): class ConsensusDocEvent(DocEvent): consensus = models.BooleanField(null=True, default=None) +class RpcAssignmentDocEvent(DocEvent): + assignments = models.TextField(blank=True) + # IESG events class BallotType(models.Model): doc_type = ForeignKey(DocTypeName, blank=True, null=True) diff --git a/ietf/doc/resources.py b/ietf/doc/resources.py index 1d86df78d0..9da7cb57d8 100644 --- a/ietf/doc/resources.py +++ b/ietf/doc/resources.py @@ -19,7 +19,7 @@ ReviewRequestDocEvent, ReviewAssignmentDocEvent, EditedAuthorsDocEvent, DocumentURL, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder, BofreqEditorDocEvent, BofreqResponsibleDocEvent, StoredObject, RfcAuthor, - EditedRfcAuthorsDocEvent) + EditedRfcAuthorsDocEvent, RpcAssignmentDocEvent) from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource class BallotTypeResource(ModelResource): @@ -916,3 +916,28 @@ class Meta: "email": ALL_WITH_RELATIONS, } api.doc.register(RfcAuthorResource()) + + +from ietf.person.resources import PersonResource +class RpcAssignmentDocEventResource(ModelResource): + by = ToOneField(PersonResource, 'by') + doc = ToOneField(DocumentResource, 'doc') + docevent_ptr = ToOneField(DocEventResource, 'docevent_ptr') + class Meta: + queryset = RpcAssignmentDocEvent.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'rpcassignmentdocevent' + ordering = ['docevent_ptr', ] + filtering = { + "id": ALL, + "time": ALL, + "type": ALL, + "rev": ALL, + "desc": ALL, + "assignments": ALL, + "by": ALL_WITH_RELATIONS, + "doc": ALL_WITH_RELATIONS, + "docevent_ptr": ALL_WITH_RELATIONS, + } +api.doc.register(RpcAssignmentDocEventResource()) diff --git a/ietf/doc/tasks.py b/ietf/doc/tasks.py index 273242e35f..37c235b911 100644 --- a/ietf/doc/tasks.py +++ b/ietf/doc/tasks.py @@ -212,10 +212,14 @@ def update_rfc_searchindex_task(self, rfc_number: int): @shared_task -def rebuild_searchindex_task(*, batchsize=40, drop_collection=False): +def rebuild_searchindex_task( + *, batchsize=40, drop_collection=False, upsert_presets=True +): if drop_collection: searchindex.delete_collection() searchindex.create_collection() + if upsert_presets: + searchindex.upsert_presets() # ok if they already exist searchindex.update_or_create_rfc_entries( Document.objects.filter(type_id="rfc").order_by("-rfc_number"), batchsize=batchsize, diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index f92c9648e6..ff4461d466 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -1023,6 +1023,34 @@ def test_edit_authors_permissions(self): draft = Document.objects.get(pk=draft.pk) self.assertEqual(draft.author_persons(), orig_authors + [new_auth_person]) + def test_edit_authors_blocked_when_rfcauthors_exist(self): + """edit_authors returns 403 for all users when RfcAuthors exist""" + rfc = WgRfcFactory() + RfcAuthorFactory(document=rfc) + url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=rfc.name)) + + self.client.login(username='secretary', password='secretary+password') + r = self.client.get(url) + self.assertEqual(r.status_code, 403) + r = self.client.post(url, {}) + self.assertEqual(r.status_code, 403) + + def test_document_main_hides_edit_authors_when_rfcauthors_exist(self): + """document_main does not offer edit link for authors when RfcAuthors exist""" + rfc = WgRfcFactory() + edit_authors_url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=rfc.name)) + + self.client.login(username='secretary', password='secretary+password') + + r = self.client.get(urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=rfc.name))) + self.assertEqual(r.status_code, 200) + self.assertContains(r, edit_authors_url) + + RfcAuthorFactory(document=rfc) + r = self.client.get(urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=rfc.name))) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, edit_authors_url) + def make_edit_authors_post_data(self, basis, authors): """Helper to generate edit_authors POST data for a set of authors""" def _add_prefix(s): @@ -2065,9 +2093,9 @@ def test_rfc_feed(self): self.assertEqual(len(q("item")), 3) item = q("item")[0] media_content = item.findall("{http://search.yahoo.com/mrss/}content") - self.assertEqual(len(media_content), 3) + self.assertEqual(len(media_content), 2) types = set([m.attrib["type"] for m in media_content]) - self.assertEqual(types, set(["text/plain", "text/html", "application/pdf"])) + self.assertEqual(types, set(["text/plain", "text/html"])) def test_state_help(self): url = urlreverse('ietf.doc.views_help.state_help', kwargs=dict(type="draft-iesg")) diff --git a/ietf/doc/tests_ballot.py b/ietf/doc/tests_ballot.py index 8420e411e2..517a2ce056 100644 --- a/ietf/doc/tests_ballot.py +++ b/ietf/doc/tests_ballot.py @@ -3,7 +3,6 @@ import datetime -from unittest import mock from pyquery import PyQuery @@ -716,11 +715,8 @@ def verify_can_see(username, url): verify_can_see(username, url) class ApproveBallotTests(TestCase): - @mock.patch('ietf.sync.rfceditor.requests.post', autospec=True) - def test_approve_ballot(self, mock_urlopen): - mock_urlopen.return_value.text = b'OK' - mock_urlopen.return_value.status_code = 200 - # + def test_approve_ballot(self): + ad = Person.objects.get(name="Areað Irector") draft = IndividualDraftFactory(ad=ad, intended_std_level_id='ps') draft.set_state(State.objects.get(used=True, type="draft-iesg", slug="iesg-eva")) # make sure it's approvable diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index 21a873c5c0..db9dbc2baf 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -6,7 +6,6 @@ import os import datetime import io -from unittest import mock from collections import Counter from pathlib import Path @@ -1549,11 +1548,8 @@ def test_confirm_submission_no_doc_ad(self): class RequestPublicationTests(TestCase): - @mock.patch('ietf.sync.rfceditor.requests.post', autospec=True) - def test_request_publication(self, mockobj): - mockobj.return_value.text = b'OK' - mockobj.return_value.status_code = 200 - # + def test_request_publication(self): + draft = IndividualDraftFactory(stream_id='iab',group__acronym='iab',intended_std_level_id='inf',states=[('draft-stream-iab','approved')]) url = urlreverse('ietf.doc.views_draft.request_publication', kwargs=dict(name=draft.name)) diff --git a/ietf/doc/tests_tasks.py b/ietf/doc/tests_tasks.py index 2e2d65463f..48db95d047 100644 --- a/ietf/doc/tests_tasks.py +++ b/ietf/doc/tests_tasks.py @@ -146,13 +146,17 @@ def test_update_rfc_searchindex_task( update_rfc_searchindex_task(rfc_number=rfc.rfc_number) @mock.patch("ietf.doc.tasks.searchindex.update_or_create_rfc_entries") + @mock.patch("ietf.doc.tasks.searchindex.upsert_presets") @mock.patch("ietf.doc.tasks.searchindex.create_collection") @mock.patch("ietf.doc.tasks.searchindex.delete_collection") - def test_rebuild_searchindex_task(self, mock_delete, mock_create, mock_update): + def test_rebuild_searchindex_task( + self, mock_delete, mock_create, mock_presets, mock_update + ): rfcs = WgRfcFactory.create_batch(10) rebuild_searchindex_task() self.assertFalse(mock_delete.called) self.assertFalse(mock_create.called) + self.assertTrue(mock_presets.called) self.assertTrue(mock_update.called) self.assertQuerysetEqual( mock_update.call_args.args[0], @@ -162,10 +166,12 @@ def test_rebuild_searchindex_task(self, mock_delete, mock_create, mock_update): mock_delete.reset_mock() mock_create.reset_mock() + mock_presets.reset_mock() mock_update.reset_mock() rebuild_searchindex_task(drop_collection=True) self.assertTrue(mock_delete.called) self.assertTrue(mock_create.called) + self.assertTrue(mock_presets.called) self.assertTrue(mock_update.called) self.assertQuerysetEqual( mock_update.call_args.args[0], @@ -175,10 +181,14 @@ def test_rebuild_searchindex_task(self, mock_delete, mock_create, mock_update): mock_delete.reset_mock() mock_create.reset_mock() + mock_presets.reset_mock() mock_update.reset_mock() - rebuild_searchindex_task(drop_collection=True, batchsize=3) + rebuild_searchindex_task( + drop_collection=True, batchsize=3, upsert_presets=False + ) self.assertTrue(mock_delete.called) self.assertTrue(mock_create.called) + self.assertFalse(mock_presets.called) self.assertTrue(mock_update.called) self.assertQuerysetEqual( mock_update.call_args.args[0], diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 6f32ed454f..5f8f587c59 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -1274,9 +1274,6 @@ def build_file_urls(doc: Union[Document, DocHistory]): label = "plain text" if t == "txt" else t file_urls.append((label, base + doc.name + "." + t)) - if "pdf" not in found_types and "txt" in found_types: - file_urls.append(("pdf", base + "pdfrfc/" + doc.name + ".txt.pdf")) - if "txt" in found_types: file_urls.append(("htmlized", urlreverse('ietf.doc.views_doc.document_html', kwargs=dict(name=doc.name)))) if doc.tags.filter(slug="verified-errata").exists(): diff --git a/ietf/doc/views_ballot.py b/ietf/doc/views_ballot.py index 03cf01a4a1..29aadfdb9b 100644 --- a/ietf/doc/views_ballot.py +++ b/ietf/doc/views_ballot.py @@ -939,16 +939,6 @@ def approve_ballot(request, name): if ballot_writeup_event.pk == None: ballot_writeup_event.save() - if new_state.slug == "ann" and new_state.slug != prev_state.slug: - # start by notifying the RFC Editor - import ietf.sync.rfceditor - response, error = ietf.sync.rfceditor.post_approved_draft(settings.RFC_EDITOR_SYNC_NOTIFICATION_URL, doc.name) - if error: - return render(request, 'doc/draft/rfceditor_post_approved_draft_failed.html', - dict(name=doc.name, - response=response, - error=error)) - doc.set_state(new_state) doc.tags.remove(*prev_tags) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 5b57a62074..af056f6a96 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -46,7 +46,7 @@ from django.core.files.base import ContentFile from django.core.exceptions import PermissionDenied from django.db.models import Max -from django.http import FileResponse, HttpResponse, Http404, HttpResponseBadRequest, JsonResponse +from django.http import FileResponse, HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse from django.shortcuts import render, get_object_or_404, redirect from django.template.loader import render_to_string from django.urls import reverse as urlreverse @@ -258,7 +258,7 @@ def document_main(request, name, rev=None, document_html=False): interesting_relations_that, interesting_relations_that_doc = interesting_doc_relations(doc) can_edit = has_role(request.user, ("Area Director", "Secretariat")) - can_edit_authors = has_role(request.user, ("Secretariat")) + can_edit_authors = has_role(request.user, ("Secretariat")) and not doc.rfcauthor_set.exists() stream_slugs = StreamName.objects.values_list("slug", flat=True) # For some reason, AnonymousUser has __iter__, but is not iterable, @@ -1842,12 +1842,15 @@ def add_fields(self, form, index): if fh in form.fields: form.fields[fh].widget = forms.HiddenInput() + doc = get_object_or_404(Document, name=name) + if doc.rfcauthor_set.exists(): + return HttpResponseForbidden("Contact the RFC Editor to change RFC Author information") + AuthorFormSet = forms.formset_factory(DocAuthorForm, formset=_AuthorsBaseFormSet, can_delete=True, can_order=True, extra=0) - doc = get_object_or_404(Document, name=name) if request.method == 'POST': change_basis_form = DocAuthorChangeBasisForm(request.POST) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index c5faf1140b..0f5ea49f5d 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -1276,15 +1276,6 @@ class PublicationForm(forms.Form): if form.is_valid(): events = [] - # start by notifying the RFC Editor - import ietf.sync.rfceditor - response, error = ietf.sync.rfceditor.post_approved_draft(settings.RFC_EDITOR_SYNC_NOTIFICATION_URL, doc.name) - if error: - return render(request, 'doc/draft/rfceditor_post_approved_draft_failed.html', - dict(name=doc.name, - response=response, - error=error)) - m.subject = form.cleaned_data["subject"] m.body = form.cleaned_data["body"] m.save() diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 64e26e503a..798fa9178e 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -666,7 +666,7 @@ }, { "fields": { - "desc": "The individual submission document has been adopted by the Working Group (WG), but a WG document replacing this document with the typical naming convention of 'draft- ietf-wgname-topic-nn' has not yet been submitted.", + "desc": "The individual submission document has been adopted by the Working Group (WG), but some administrative matter still needs to be completed (e.g., a WG document replacing this document with the typical naming convention of 'draft-ietf-wgname-topic-nn' has not yet been submitted).", "name": "Adopted by a WG", "next_states": [ 38 @@ -694,7 +694,7 @@ }, { "fields": { - "desc": "The document has been adopted by the Working Group (WG) and is under development. A document can only be adopted by one WG at a time. However, a document may be transferred between WGs.", + "desc": "The document has been identified as a Working Group (WG) document and is under development per Section 7.2 of RFC2418.", "name": "WG Document", "next_states": [ 39, @@ -759,7 +759,7 @@ }, { "fields": { - "desc": "The Working Group (WG) document has completed Working Group Last Call (WGLC), but the WG chair(s) are not yet ready to call consensus on the document. The reasons for this may include comments from the WGLC need to be responded to, or a revision to the document is needed", + "desc": "The Working Group (WG) document has completed Working Group Last Call (WGLC), but the WG chairs are not yet ready to call consensus on the document. The reasons for this may include comments from the WGLC need to be responded to, or a revision to the document is needed.", "name": "Waiting for WG Chair Go-Ahead", "next_states": [ 41, @@ -790,7 +790,7 @@ }, { "fields": { - "desc": "The Working Group (WG) document has left the WG and been submitted to the Internet Engineering Steering Group (IESG) for evaluation and publication. See the “IESG State” or “RFC Editor State” for further details on the state of the document.", + "desc": "The Working Group (WG) document has been submitted to the Internet Engineering Steering Group (IESG) for evaluation and publication per Section 7.4 of RFC2418. See the “IESG State” or “RFC Editor State” for further details on the state of the document.", "name": "Submitted to IESG for Publication", "next_states": [ 38 @@ -2656,6 +2656,32 @@ "model": "doc.state", "pk": 183 }, + { + "fields": { + "desc": "", + "name": "In Progress", + "next_states": [], + "order": 0, + "slug": "in_progress", + "type": "draft-rfceditor", + "used": true + }, + "model": "doc.state", + "pk": 216 + }, + { + "fields": { + "desc": "", + "name": "Blocked", + "next_states": [], + "order": 0, + "slug": "blocked", + "type": "draft-rfceditor", + "used": true + }, + "model": "doc.state", + "pk": 217 + }, { "fields": { "label": "State" @@ -5816,6 +5842,20 @@ "model": "mailtrigger.mailtrigger", "pk": "review_completed_artart_telechat" }, + { + "fields": { + "cc": [ + "review_doc_all_parties", + "review_doc_group_mail_list" + ], + "desc": "Recipients when a bgpdir Early review is completed", + "to": [ + "review_team_mail_list" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "review_completed_bgpdir_early" + }, { "fields": { "cc": [ @@ -6124,6 +6164,20 @@ "model": "mailtrigger.mailtrigger", "pk": "review_completed_opsdir_telechat" }, + { + "fields": { + "cc": [ + "review_doc_all_parties", + "review_doc_group_mail_list" + ], + "desc": "Recipients when a perfmetrdir Early review is completed", + "to": [ + "review_team_mail_list" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "review_completed_perfmetrdir_early" + }, { "fields": { "cc": [ @@ -14038,6 +14092,16 @@ "model": "name.reviewresultname", "pk": "almost-ready" }, + { + "fields": { + "desc": "", + "name": "Clarification Needed", + "order": 10, + "used": true + }, + "model": "name.reviewresultname", + "pk": "clarification-needed" + }, { "fields": { "desc": "", @@ -17757,5 +17821,13 @@ }, "model": "stats.countryalias", "pk": 303 + }, + { + "fields": { + "alias": "czechia", + "country": "CZ" + }, + "model": "stats.countryalias", + "pk": 304 } ] diff --git a/ietf/person/templatetags/person_filters.py b/ietf/person/templatetags/person_filters.py index 017b29c63a..a7a6e8193a 100644 --- a/ietf/person/templatetags/person_filters.py +++ b/ietf/person/templatetags/person_filters.py @@ -50,6 +50,7 @@ def person_link(person, **kwargs): title = kwargs.get("title", "") cls = kwargs.get("class", "") with_email = kwargs.get("with_email", True) + titlepage_name = kwargs.get("titlepage_name", None) if person is not None: plain_name = person.plain_name() name = ( @@ -61,6 +62,7 @@ def person_link(person, **kwargs): return { "name": name, "plain_name": plain_name, + "titlepage_name": titlepage_name, "email": email, "title": title, "class": cls, diff --git a/ietf/person/templatetags/tests.py b/ietf/person/templatetags/tests.py index 327cfad6ce..7c35fd6b69 100644 --- a/ietf/person/templatetags/tests.py +++ b/ietf/person/templatetags/tests.py @@ -1,4 +1,6 @@ # Copyright The IETF Trust 2022, All Rights Reserved +from django.template.loader import render_to_string + from ietf.person.factories import PersonFactory from ietf.utils.test_utils import TestCase @@ -8,7 +10,6 @@ class PersonLinkTests(TestCase): # Tests of the person_link template tag. These assume it is implemented as an # inclusion tag. - # TODO test that the template actually renders the data in the dict def test_person_link(self): person = PersonFactory() self.assertEqual( @@ -16,6 +17,7 @@ def test_person_link(self): { 'name': person.name, 'plain_name': person.plain_name(), + 'titlepage_name': None, 'email': person.email_address(), 'title': '', 'class': '', @@ -27,6 +29,7 @@ def test_person_link(self): { 'name': person.name, 'plain_name': person.plain_name(), + 'titlepage_name': None, 'email': person.email_address(), 'title': '', 'class': '', @@ -38,6 +41,7 @@ def test_person_link(self): { 'name': person.name, 'plain_name': person.plain_name(), + 'titlepage_name': None, 'email': person.email_address(), 'title': 'Random Title', 'class': '', @@ -50,12 +54,71 @@ def test_person_link(self): { 'name': person.name, 'plain_name': person.plain_name(), + 'titlepage_name': None, 'email': person.email_address(), 'title': '', 'class': 'some-class', 'with_email': True, } ) + self.assertEqual( + person_link(person, titlepage_name='G. Surname'), + { + 'name': person.name, + 'plain_name': person.plain_name(), + 'titlepage_name': 'G. Surname', + 'email': person.email_address(), + 'title': '', + 'class': '', + 'with_email': True, + } + ) + + def test_person_link_renders(self): + """Verifies person/person_link.html renders context dict values correctly.""" + person = PersonFactory() + name = person.name + email = person.email_address() + base_context = { + 'name': name, + 'plain_name': person.plain_name(), + 'titlepage_name': None, + 'email': email, + 'title': '', + 'class': '', + 'with_email': True, + } + + # Default: name is used as link text with default title attribute + html = render_to_string('person/person_link.html', base_context) + self.assertIn(f'>{name}', html) + self.assertIn(f'Datatracker profile of {name}', html) + self.assertIn('bi-envelope', html) + + # titlepage_name overrides name as link text + html = render_to_string('person/person_link.html', {**base_context, 'titlepage_name': 'G. Surname'}) + self.assertIn('>G. Surname', html) + self.assertNotIn(f'>{name}', html) + + # with_email=False suppresses the envelope link + html = render_to_string('person/person_link.html', {**base_context, 'with_email': False}) + self.assertNotIn('bi-envelope', html) + + # Custom title appears in the anchor title attribute + html = render_to_string('person/person_link.html', {**base_context, 'title': 'Special Title'}) + self.assertIn('title="Special Title"', html) + + # Empty context (None person) renders (None) + self.assertInHTML( + '(None)', + render_to_string('person/person_link.html', {}), + ) + + # System email renders (System) + self.assertInHTML( + '(System)', + render_to_string('person/person_link.html', {'email': 'system@datatracker.ietf.org', 'name': ''}), + ) def test_invalid_person(self): """Generates correct context dict when input is invalid/missing""" diff --git a/ietf/settings.py b/ietf/settings.py index 50e069ff1a..95f2ffefd7 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -893,16 +893,14 @@ def skip_unreadable_post(record): IANA_SYNC_CHANGES_URL = "https://datatracker.iana.org:4443/data-tracker/changes" IANA_SYNC_PROTOCOLS_URL = "https://www.iana.org/protocols/" -RFC_EDITOR_SYNC_PASSWORD="secret" -RFC_EDITOR_SYNC_NOTIFICATION_URL = "https://www.rfc-editor.org/parser/parser.php" RFC_EDITOR_GROUP_NOTIFICATION_EMAIL = "webmaster@rfc-editor.org" -#RFC_EDITOR_GROUP_NOTIFICATION_URL = "https://www.rfc-editor.org/notification/group.php" RFC_EDITOR_QUEUE_URL = "https://www.rfc-editor.org/queue2.xml" RFC_EDITOR_INDEX_URL = "https://www.rfc-editor.org/rfc/rfc-index.xml" RFC_EDITOR_ERRATA_JSON_URL = "https://www.rfc-editor.org/errata.json" RFC_EDITOR_INLINE_ERRATA_URL = "https://www.rfc-editor.org/rfc/inline-errata/rfc{rfc_number}.html" RFC_EDITOR_ERRATA_BASE_URL = "https://www.rfc-editor.org/errata/" RFC_EDITOR_INFO_BASE_URL = "https://www.rfc-editor.org/info/" +RFC_EDITOR_QUEUE_SITE_BASE_URL = "https://queue.rfc-editor.org" # NomCom Tool settings @@ -1261,7 +1259,10 @@ def skip_unreadable_post(record): 'patch/change-oidc-provider-field-sizes-228.patch', 'patch/fix-oidc-access-token-post.patch', 'patch/fix-jwkest-jwt-logging.patch', - 'patch/django-cookie-delete-with-all-settings.patch', + # Patch includes old cookie-delete-with-all-settings and a backport of the fix + # to CVE-2026-35192 from Django 5.2. The patches conflict, so cannot be applied + # separately. + 'patch/django-cookie-delete-settings-and-CVE-2026-35192.patch', 'patch/tastypie-django22-fielderror-response.patch', ] if DEBUG: diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py index 373f06e343..dc5b5d6ae8 100644 --- a/ietf/stats/tests.py +++ b/ietf/stats/tests.py @@ -1,18 +1,19 @@ -# Copyright The IETF Trust 2016-2020, All Rights Reserved -# -*- coding: utf-8 -*- - +# Copyright The IETF Trust 2016-2026, All Rights Reserved import calendar import json import datetime +from django.http import Http404 from pyquery import PyQuery import debug # pyflakes:ignore +from django.test import RequestFactory from django.urls import reverse as urlreverse from django.utils import timezone +from ietf.meeting.models import Meeting from ietf.utils.test_utils import login_testing_unauthorized, TestCase import ietf.stats.views @@ -87,7 +88,29 @@ def test_meeting_stats(self): self.assertContains(r, "/stats/meeting/124/country") self.assertContains(r, "/stats/meeting/125/country") self.assertContains(r, "This page provides a timeline of meeting registrations.") - + + def test_meeting_stats_for_bad_meeting(self): + self.assertFalse(Meeting.objects.filter(number=676767).exists()) + for stats_type in ["affiliation", "country"]: + r = self.client.get( + urlreverse( + "ietf.stats.views.meeting_stats", + kwargs={"meeting_number": 676767, "stats_type": stats_type}, + ) + ) + self.assertEqual(r.status_code, 404) + + # We don't have a URL for an interim, but make sure the view will 404 if + # somehow a non-interim gets selected... + interim_num = MeetingFactory(type_id="interim").number + request_factory = RequestFactory() + with self.assertRaises(Http404): + ietf.stats.views.meeting_stats( + request_factory.get(f"/stats/meeting/{interim_num}/{stats_type}"), + meeting_number=interim_num, + stats_type=stats_type, + ) + def test_known_country_list(self): # check redirect url = urlreverse(ietf.stats.views.known_countries_list) diff --git a/ietf/stats/views.py b/ietf/stats/views.py index d61b673075..d61c9cab64 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required from django.core.cache import cache from django.http import HttpResponseRedirect -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from django.urls import reverse as urlreverse from django.db.models import Count @@ -27,11 +27,11 @@ from ietf.group.models import Role, Group from ietf.person.models import Person from ietf.name.models import ReviewResultName, CountryName, ReviewAssignmentStateName -from ietf.meeting.models import Registration +from ietf.meeting.models import Registration, Meeting from ietf.ietfauth.utils import has_role from ietf.utils.response import permission_denied from ietf.utils.timezone import date_today, DEADLINE_TZINFO -from ietf.meeting.helpers import get_current_ietf_meeting_num, get_ietf_meeting +from ietf.meeting.helpers import get_current_ietf_meeting_num # Color palette for lines colors = [ @@ -568,12 +568,12 @@ def meeting_stats(request, meeting_number=None, stats_type='country'): Returns: Rendered response for the meeting stats template. """ - - current_meeting = get_current_ietf_meeting_num() + current_meeting_number = get_current_ietf_meeting_num() if meeting_number is None: - meeting_number = current_meeting - - this_meeting = get_ietf_meeting(meeting_number) + meeting_number = current_meeting_number + this_meeting = get_object_or_404( + Meeting.objects.filter(type_id="ietf"), number=meeting_number + ) if stats_type == 'affiliation': minimum_required = 4 @@ -616,7 +616,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'): if int(meeting_number) > 72: # No registration data before IETF-72 possible_meeting_numbers.append((int(meeting_number)-1, urlreverse(meeting_stats, kwargs={'meeting_number': int(meeting_number)-1, 'stats_type': stats_type}))) possible_meeting_numbers.append((meeting_number, urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': stats_type}))) - if int(meeting_number) <= int(current_meeting): # Allow current meeting +1 + if int(meeting_number) <= int(current_meeting_number): # Allow current meeting +1 possible_meeting_numbers.append((int(meeting_number)+1, urlreverse(meeting_stats, kwargs={'meeting_number': int(meeting_number)+1, 'stats_type': stats_type}))) return render(request, "stats/meeting_stats.html", { diff --git a/ietf/submit/utils.py b/ietf/submit/utils.py index 7e3106f723..457462e4f2 100644 --- a/ietf/submit/utils.py +++ b/ietf/submit/utils.py @@ -1612,6 +1612,4 @@ def active(dirent): log.log(f"Error processing {item.name}: {e}") ftp_moddir = Path(settings.FTP_DIR) / "yang" / "draftmod/" - if not moddir.endswith("/"): - moddir += "/" - subprocess.call(("/usr/bin/rsync", "-aq", "--delete", moddir, ftp_moddir)) + subprocess.call(("/usr/bin/rsync", "-aq", "--delete", f"{moddir}/", str(ftp_moddir))) diff --git a/ietf/sync/rfceditor.py b/ietf/sync/rfceditor.py index aa0e643b20..347b58efbb 100644 --- a/ietf/sync/rfceditor.py +++ b/ietf/sync/rfceditor.py @@ -2,20 +2,15 @@ # -*- coding: utf-8 -*- -import base64 import datetime import re -import requests from typing import Iterator, Optional, Union -from urllib.parse import urlencode from xml.dom import pulldom, Node -from django.conf import settings from django.db import transaction from django.db.models import Subquery, OuterRef, F, Q from django.utils import timezone -from django.utils.encoding import smart_bytes, force_str import debug # pyflakes:ignore @@ -847,50 +842,4 @@ def parse_relation_list(rel_list: list[str]) -> list[Document]: ).update(document=F("subseries_target")) -def post_approved_draft(url, name): - """Post an approved draft to the RFC Editor so they can retrieve - the data from the Datatracker and start processing it. Returns - response and error (empty string if no error).""" - - if settings.SERVER_MODE != "production": - log(f"In production, would have posted RFC-Editor notification of approved I-D '{name}' to '{url}'") - return "", "" - - # HTTP basic auth - username = "dtracksync" - password = settings.RFC_EDITOR_SYNC_PASSWORD - headers = { - "Content-type": "application/x-www-form-urlencoded", - "Accept": "text/plain", - "Authorization": "Basic %s" % force_str(base64.encodebytes(smart_bytes("%s:%s" % (username, password)))).replace("\n", ""), - } - - log("Posting RFC-Editor notification of approved Internet-Draft '%s' to '%s'" % (name, url)) - text = error = "" - - try: - r = requests.post( - url, - headers=headers, - data=smart_bytes(urlencode({ 'draft': name })), - timeout=settings.DEFAULT_REQUESTS_TIMEOUT, - ) - - log("RFC-Editor notification result for Internet-Draft '%s': %s:'%s'" % (name, r.status_code, r.text)) - - if r.status_code != 200: - raise RuntimeError("Status code is not 200 OK (it's %s)." % r.status_code) - - if force_str(r.text) != "OK": - raise RuntimeError('Response is not "OK" (it\'s "%s").' % r.text) - - except Exception as e: - # catch everything so we don't leak exceptions, convert them - # into string instead - msg = "Exception on RFC-Editor notification for Internet-Draft '%s': %s: %s" % (name, type(e), str(e)) - log(msg) - if settings.SERVER_MODE == 'test': - debug.say(msg) - error = str(e) - return text, error diff --git a/ietf/sync/tasks.py b/ietf/sync/tasks.py index 34b2efeb5c..3808fb1db5 100644 --- a/ietf/sync/tasks.py +++ b/ietf/sync/tasks.py @@ -13,8 +13,11 @@ from django.conf import settings from django.utils import timezone -from ietf.doc.models import DocEvent, RelatedDocument +from ietf.doc.models import DocEvent, DocTagName, Document, RelatedDocument, RpcAssignmentDocEvent, State from ietf.doc.tasks import rebuild_reference_relations_task +from ietf.doc.utils import add_state_change_event, new_state_change_event, update_action_holders +from ietf.person.models import Person +from ietf.utils.mail import send_mail_text from ietf.sync import iana from ietf.sync import rfceditor from ietf.sync.errata import ( @@ -342,3 +345,127 @@ def refresh_rfc_index_task(): pass mark_rfcindex_as_processed(new_processed_time) + + +@shared_task +def process_rpc_queue_task(data: list): + in_progress_state = State.objects.get( + used=True, type="draft-rfceditor", slug="in_progress" + ) + blocked_state = State.objects.get(used=True, type="draft-rfceditor", slug="blocked") + system = Person.objects.get(name="(System)") + iana_ref_tags = list(DocTagName.objects.filter(slug__in=["iana", "ref"])) + + names = [obj["name"] for obj in data] + docs_in_db = { + d.name: d for d in Document.objects.filter(type="draft", name__in=names) + } + + for obj in data: + name = obj["name"] + if name not in docs_in_db: + log.log(f"process_rpc_queue_task: unknown document {name}") + continue + + d = docs_in_db[name] + events = [] + prev_state = d.get_state("draft-rfceditor") + + # Same check as ietf.sync.rfceditor.update_drafts_from_queue: + # if this document just arrived at the RFC Editor for the first time, record it. + if ( + d.get_state_slug("draft-iesg") == "ann" + and not prev_state + and not d.latest_event(DocEvent, type="rfc_editor_received_announcement") + ): + e = DocEvent( + doc=d, rev=d.rev, by=system, type="rfc_editor_received_announcement" + ) + e.desc = "Announcement was received by RFC Editor" + e.save() + send_mail_text( + None, + "iesg-secretary@ietf.org", + None, + "%s in RFC Editor queue" % d.name, + "The announcement for %s has been received by the RFC Editor." % d.name, + ) + prev_iesg_state = State.objects.get( + used=True, type="draft-iesg", slug="ann" + ) + next_iesg_state = State.objects.get( + used=True, type="draft-iesg", slug="rfcqueue" + ) + d.set_state(next_iesg_state) + e = add_state_change_event(d, system, prev_iesg_state, next_iesg_state) + if e: + events.append(e) + e = update_action_holders(d, prev_iesg_state, next_iesg_state) + if e: + events.append(e) + + is_blocked = any(a["role"] == "blocked" for a in obj.get("assignment_set", [])) + next_state = blocked_state if is_blocked else in_progress_state + + if prev_state != next_state: + d.set_state(next_state) + e = new_state_change_event(d, system, prev_state, next_state) + if e: + e.save() + events.append(e) + + roles = sorted(a["role"] for a in obj.get("assignment_set", [])) + next_assignments = ", ".join(roles) + blocking_names = sorted( + br["reason"]["name"] for br in obj.get("blocking_reasons", []) + ) + if blocking_names: + next_assignments += ": " + ", ".join(blocking_names) + + if next_assignments == "": + next_assignments = "Awaiting Editor Assignment" + + prev_assignments_event = d.latest_event( + RpcAssignmentDocEvent, type="changed_rpc_assignments" + ) + prev_assignments = ( + prev_assignments_event.assignments if prev_assignments_event else None + ) + + if next_assignments != prev_assignments: + e = RpcAssignmentDocEvent( + doc=d, + rev=d.rev, + by=system, + type="changed_rpc_assignments", + assignments=next_assignments, + ) + e.desc = f"RPC status changed to {next_assignments}" + if prev_assignments is not None and prev_assignments != "": + e.desc += f" from {prev_assignments}" + e.save() + events.append(e) + + rfc_number = obj.get("rfc_number") + if obj.get("final_approval") and rfc_number: + d.documenturl_set.update_or_create( + tag_id="auth48", + defaults=dict( + url=f"{settings.RFC_EDITOR_QUEUE_SITE_BASE_URL}/final-review/rfc{rfc_number}/" + ), + ) + else: + d.documenturl_set.filter(tag_id="auth48").delete() + + d.tags.remove(*iana_ref_tags) + + if events: + d.save_with_history(events) + + for d in ( + Document.objects.exclude(name__in=names) + .filter(states__type="draft-rfceditor") + .distinct() + ): + d.tags.remove(*iana_ref_tags) + d.unset_state("draft-rfceditor") diff --git a/ietf/sync/tests.py b/ietf/sync/tests.py index e83b6a5e0a..207c78cf6a 100644 --- a/ietf/sync/tests.py +++ b/ietf/sync/tests.py @@ -795,30 +795,6 @@ def test_update_draft_auth48_url(self): auth48_docurl = draft.documenturl_set.filter(tag_id='auth48').first() self.assertIsNone(auth48_docurl) - def test_post_approved_draft_in_production_only(self): - self.requests_mock.post("https://rfceditor.example.com/", status_code=200, text="OK") - - # be careful playing with SERVER_MODE! - with override_settings(SERVER_MODE="test"): - self.assertEqual( - rfceditor.post_approved_draft("https://rfceditor.example.com/", "some-draft"), - ("", "") - ) - self.assertFalse(self.requests_mock.called) - with override_settings(SERVER_MODE="development"): - self.assertEqual( - rfceditor.post_approved_draft("https://rfceditor.example.com/", "some-draft"), - ("", "") - ) - self.assertFalse(self.requests_mock.called) - with override_settings(SERVER_MODE="production"): - self.assertEqual( - rfceditor.post_approved_draft("https://rfceditor.example.com/", "some-draft"), - ("", "") - ) - self.assertTrue(self.requests_mock.called) - - class DiscrepanciesTests(TestCase): def test_discrepancies(self): diff --git a/ietf/sync/tests_rfcindex.py b/ietf/sync/tests_rfcindex.py index 74fa9e7616..2b70924db3 100644 --- a/ietf/sync/tests_rfcindex.py +++ b/ietf/sync/tests_rfcindex.py @@ -1,5 +1,6 @@ # Copyright The IETF Trust 2026, All Rights Reserved import json +import re from pathlib import Path from unittest import mock @@ -131,17 +132,20 @@ def test_create_rfc_txt_index(self, mock_save_blob, mock_save_file): "0123 Not Issued.", contents, ) + + # strip whitespace so line breaks don't interfere with the next few tests + stripped_contents = re.sub(r"\s+", " ", mock_save_blob.call_args[0][1]) self.assertIn( f"{self.april_fools_rfc.rfc_number} {self.april_fools_rfc.title}", - contents, + stripped_contents, ) self.assertIn("1 April 2020", contents) # from the April 1 RFC self.assertIn( f"{self.rfc.rfc_number} {self.rfc.title}", - contents, + stripped_contents, ) - self.assertIn("April 2021", contents) # from the non-April 1 RFC - self.assertNotIn("1 April 2021", contents) + self.assertIn("April 2021", stripped_contents) # from the non-April 1 RFC + self.assertNotIn("1 April 2021", stripped_contents) @override_settings(RFCINDEX_INPUT_PATH="input/") @mock.patch("ietf.sync.rfcindex.save_to_filesystem") diff --git a/ietf/sync/tests_tasks.py b/ietf/sync/tests_tasks.py new file mode 100644 index 0000000000..6657dd617a --- /dev/null +++ b/ietf/sync/tests_tasks.py @@ -0,0 +1,476 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from django.test.utils import override_settings + +from ietf.doc.factories import WgDraftFactory +from ietf.doc.models import ( + DocEvent, + DocTagName, + Document, + DocumentURL, + RpcAssignmentDocEvent, + State, +) +from ietf.person.models import Person +from ietf.sync import tasks +from ietf.utils.mail import outbox +from ietf.utils.test_utils import TestCase + + +def _make_entry( + doc_name, roles=None, blocking_reasons=None, rfc_number=None, final_approval=None +): + return { + "name": doc_name, + "assignment_set": [{"role": r, "state": "in_progress"} for r in (roles or [])], + "blocking_reasons": blocking_reasons or [], + "rfc_number": rfc_number, + "final_approval": final_approval or [], + } + + +class ProcessRpcQueueTaskTests(TestCase): + def setUp(self): + super().setUp() + self.system = Person.objects.get(name="(System)") + + # --- Unknown document -------------------------------------------------------- + + def test_unknown_document_is_skipped(self): + """Entries with unknown doc names are logged and skipped without raising.""" + tasks.process_rpc_queue_task([_make_entry("draft-does-not-exist")]) + self.assertFalse(Document.objects.filter(name="draft-does-not-exist").exists()) + + # --- First-arrival announcement ---------------------------------------------- + + def test_first_arrival_fires_announcement(self): + """Fires rfc_editor_received_announcement and email on first arrival.""" + draft = WgDraftFactory(states=[("draft-iesg", "ann")]) + mailbox_before = len(outbox) + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + draft = Document.objects.get(pk=draft.pk) + self.assertEqual(draft.get_state_slug("draft-iesg"), "rfcqueue") + self.assertTrue( + draft.docevent_set.filter(type="rfc_editor_received_announcement").exists() + ) + self.assertEqual(len(outbox), mailbox_before + 1) + self.assertIn("RFC Editor queue", outbox[-1]["Subject"]) + self.assertIn("iesg-secretary@ietf.org", outbox[-1]["To"]) + + def test_first_arrival_skipped_if_rfceditor_state_exists(self): + """No announcement when doc already has a draft-rfceditor state.""" + draft = WgDraftFactory(states=[("draft-iesg", "ann")]) + draft.set_state( + State.objects.get(used=True, type="draft-rfceditor", slug="in_progress") + ) + mailbox_before = len(outbox) + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + self.assertFalse( + draft.docevent_set.filter(type="rfc_editor_received_announcement").exists() + ) + self.assertEqual(len(outbox), mailbox_before) + + def test_first_arrival_skipped_if_announcement_event_exists(self): + """No duplicate announcement when rfc_editor_received_announcement already exists.""" + draft = WgDraftFactory(states=[("draft-iesg", "ann")]) + DocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=self.system, + type="rfc_editor_received_announcement", + desc="Announcement was received by RFC Editor", + ) + mailbox_before = len(outbox) + + tasks.process_rpc_queue_task([_make_entry(draft.name)]) + + self.assertEqual( + draft.docevent_set.filter(type="rfc_editor_received_announcement").count(), + 1, + ) + self.assertEqual(len(outbox), mailbox_before) + + def test_first_arrival_skipped_if_not_ann_iesg_state(self): + """No announcement when IESG state is not 'ann'.""" + draft = WgDraftFactory(states=[("draft-iesg", "rfcqueue")]) + mailbox_before = len(outbox) + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + self.assertFalse( + draft.docevent_set.filter(type="rfc_editor_received_announcement").exists() + ) + self.assertEqual(len(outbox), mailbox_before) + + # --- draft-rfceditor state transitions --------------------------------------- + + def test_sets_in_progress_state(self): + """Non-blocked assignment results in in_progress draft-rfceditor state.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + draft = Document.objects.get(pk=draft.pk) + self.assertEqual(draft.get_state_slug("draft-rfceditor"), "in_progress") + + def test_sets_blocked_state(self): + """Assignment with role='blocked' results in blocked draft-rfceditor state.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task( + [ + { + "name": draft.name, + "assignment_set": [{"role": "blocked", "state": "in_progress"}], + "blocking_reasons": [], + "rfc_number": None, + "final_approval": [], + } + ] + ) + + draft = Document.objects.get(pk=draft.pk) + self.assertEqual(draft.get_state_slug("draft-rfceditor"), "blocked") + + def test_no_state_change_event_when_state_unchanged(self): + """No state-change DocEvent created when draft-rfceditor state is already correct.""" + draft = WgDraftFactory(states=[("draft-rfceditor", "in_progress")]) + events_before = draft.docevent_set.filter(type="changed_state").count() + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + self.assertEqual( + draft.docevent_set.filter(type="changed_state").count(), events_before + ) + + def test_state_change_event_created_on_transition(self): + """State-change DocEvent is created when draft-rfceditor state changes.""" + draft = WgDraftFactory(states=[("draft-rfceditor", "in_progress")]) + + tasks.process_rpc_queue_task( + [ + { + "name": draft.name, + "assignment_set": [{"role": "blocked", "state": "in_progress"}], + "blocking_reasons": [], + "rfc_number": None, + "final_approval": [], + } + ] + ) + + self.assertTrue(draft.docevent_set.filter(type="changed_state").exists()) + draft = Document.objects.get(pk=draft.pk) + self.assertEqual(draft.get_state_slug("draft-rfceditor"), "blocked") + + # --- RpcAssignmentDocEvent --------------------------------------------------- + + def test_creates_assignment_event_on_first_update(self): + """Creates RpcAssignmentDocEvent when no prior event exists.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task( + [_make_entry(draft.name, roles=["first_editor", "second_editor"])] + ) + + event = draft.latest_event( + RpcAssignmentDocEvent, type="changed_rpc_assignments" + ) + self.assertIsNotNone(event) + self.assertEqual(event.assignments, "first_editor, second_editor") + + def test_no_assignment_event_when_unchanged(self): + """No new RpcAssignmentDocEvent when assignments match the last recorded ones.""" + draft = WgDraftFactory() + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=self.system, + type="changed_rpc_assignments", + assignments="first_editor", + desc="RPC status changed to first_editor", + ) + events_before = RpcAssignmentDocEvent.objects.filter(doc=draft).count() + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + self.assertEqual( + RpcAssignmentDocEvent.objects.filter(doc=draft).count(), events_before + ) + + def test_assignment_desc_includes_previous_assignments(self): + """Assignment event desc includes previous assignments when they exist.""" + draft = WgDraftFactory() + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=self.system, + type="changed_rpc_assignments", + assignments="first_editor", + desc="RPC status changed to first_editor", + ) + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["second_editor"])]) + + event = draft.latest_event( + RpcAssignmentDocEvent, type="changed_rpc_assignments" + ) + self.assertIn("from first_editor", event.desc) + + def test_blocking_reasons_appended_to_assignments(self): + """Blocking reason names are appended after ':' in the assignment string, sorted.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task( + [ + { + "name": draft.name, + "assignment_set": [{"role": "blocked", "state": "in_progress"}], + "blocking_reasons": [ + {"reason": {"name": "missing reference"}}, + ], + "rfc_number": None, + "final_approval": [], + } + ] + ) + + event = draft.latest_event( + RpcAssignmentDocEvent, type="changed_rpc_assignments" + ) + self.assertIsNotNone(event) + self.assertEqual(event.assignments, "blocked: missing reference") + + def test_roles_sorted_in_assignment_string(self): + """Roles are sorted alphabetically in the assignment string.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task( + [_make_entry(draft.name, roles=["second_editor", "first_editor"])] + ) + + event = draft.latest_event( + RpcAssignmentDocEvent, type="changed_rpc_assignments" + ) + self.assertEqual(event.assignments, "first_editor, second_editor") + + def test_empty_roles_uses_awaiting_editor_assignment(self): + """Empty assignment_set records 'Awaiting Editor Assignment' rather than an empty string.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task([_make_entry(draft.name)]) + + event = draft.latest_event( + RpcAssignmentDocEvent, type="changed_rpc_assignments" + ) + self.assertIsNotNone(event) + self.assertEqual(event.assignments, "Awaiting Editor Assignment") + + # --- DocumentURL (auth48) handling ------------------------------------------- + + @override_settings(RFC_EDITOR_QUEUE_SITE_BASE_URL="https://queue.example.com") + def test_auth48_url_created_on_final_approval(self): + """auth48 DocumentURL is created when final_approval is truthy and rfc_number is set.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task( + [ + { + "name": draft.name, + "assignment_set": [ + {"role": "first_editor", "state": "in_progress"} + ], + "blocking_reasons": [], + "rfc_number": 9999, + "final_approval": [{"approved": True}], + } + ] + ) + + url_obj = draft.documenturl_set.filter(tag_id="auth48").first() + self.assertIsNotNone(url_obj) + self.assertEqual(url_obj.url, "https://queue.example.com/final-review/rfc9999/") + + @override_settings(RFC_EDITOR_QUEUE_SITE_BASE_URL="https://queue.example.com") + def test_auth48_url_not_created_without_rfc_number(self): + """No auth48 URL created when rfc_number is None even if final_approval is set.""" + draft = WgDraftFactory() + + tasks.process_rpc_queue_task( + [ + { + "name": draft.name, + "assignment_set": [ + {"role": "first_editor", "state": "in_progress"} + ], + "blocking_reasons": [], + "rfc_number": None, + "final_approval": [{"approved": True}], + } + ] + ) + + self.assertFalse(draft.documenturl_set.filter(tag_id="auth48").exists()) + + @override_settings(RFC_EDITOR_QUEUE_SITE_BASE_URL="https://queue.example.com") + def test_auth48_url_deleted_when_final_approval_cleared(self): + """Existing auth48 URL is deleted whenever final_approval is empty, regardless of whether assignments changed.""" + draft = WgDraftFactory() + DocumentURL.objects.create( + doc=draft, + tag_id="auth48", + url="https://queue.example.com/final-review/rfc9999/", + ) + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=self.system, + type="changed_rpc_assignments", + assignments="old_editor", + desc="RPC status changed to old_editor", + ) + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + self.assertFalse(draft.documenturl_set.filter(tag_id="auth48").exists()) + + @override_settings(RFC_EDITOR_QUEUE_SITE_BASE_URL="https://queue.example.com") + def test_auth48_url_updated_when_rfc_number_changes(self): + """auth48 URL is updated whenever final_approval and rfc_number are set, regardless of whether assignments changed.""" + draft = WgDraftFactory() + DocumentURL.objects.create( + doc=draft, + tag_id="auth48", + url="https://queue.example.com/final-review/rfc8888/", + ) + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=self.system, + type="changed_rpc_assignments", + assignments="old_editor", + desc="RPC status changed to old_editor", + ) + + tasks.process_rpc_queue_task( + [ + { + "name": draft.name, + "assignment_set": [ + {"role": "first_editor", "state": "in_progress"} + ], + "blocking_reasons": [], + "rfc_number": 9999, + "final_approval": [{"approved": True}], + } + ] + ) + + url_obj = draft.documenturl_set.filter(tag_id="auth48").first() + self.assertIsNotNone(url_obj) + self.assertEqual(url_obj.url, "https://queue.example.com/final-review/rfc9999/") + + @override_settings(RFC_EDITOR_QUEUE_SITE_BASE_URL="https://queue.example.com") + def test_auth48_url_created_when_assignments_unchanged(self): + """auth48 URL is created even when assignments have not changed.""" + draft = WgDraftFactory() + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=self.system, + type="changed_rpc_assignments", + assignments="first_editor", + desc="RPC status changed to first_editor", + ) + + tasks.process_rpc_queue_task( + [ + { + "name": draft.name, + "assignment_set": [ + {"role": "first_editor", "state": "in_progress"} + ], + "blocking_reasons": [], + "rfc_number": 9999, + "final_approval": [{"approved": True}], + } + ] + ) + + url_obj = draft.documenturl_set.filter(tag_id="auth48").first() + self.assertIsNotNone(url_obj) + self.assertEqual(url_obj.url, "https://queue.example.com/final-review/rfc9999/") + + @override_settings(RFC_EDITOR_QUEUE_SITE_BASE_URL="https://queue.example.com") + def test_auth48_url_deleted_when_assignments_unchanged(self): + """Existing auth48 URL is deleted even when assignments have not changed.""" + draft = WgDraftFactory() + DocumentURL.objects.create( + doc=draft, + tag_id="auth48", + url="https://queue.example.com/final-review/rfc9999/", + ) + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=self.system, + type="changed_rpc_assignments", + assignments="first_editor", + desc="RPC status changed to first_editor", + ) + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + self.assertFalse(draft.documenturl_set.filter(tag_id="auth48").exists()) + + # --- Tag handling ------------------------------------------------------------ + + def test_removes_iana_and_ref_tags_from_queued_docs(self): + """iana and ref tags are removed from documents in the queue.""" + iana_tag = DocTagName.objects.get(slug="iana") + ref_tag = DocTagName.objects.get(slug="ref") + draft = WgDraftFactory() + draft.tags.add(iana_tag, ref_tag) + + tasks.process_rpc_queue_task([_make_entry(draft.name)]) + + draft = Document.objects.get(pk=draft.pk) + self.assertNotIn(iana_tag, draft.tags.all()) + self.assertNotIn(ref_tag, draft.tags.all()) + + # --- Cleanup of docs no longer in queue -------------------------------------- + + def test_unsets_rfceditor_state_for_docs_not_in_queue(self): + """Documents with draft-rfceditor state but absent from the queue have that state cleared.""" + draft = WgDraftFactory(states=[("draft-rfceditor", "in_progress")]) + + tasks.process_rpc_queue_task([]) + + draft = Document.objects.get(pk=draft.pk) + self.assertIsNone(draft.get_state("draft-rfceditor")) + + def test_removes_tags_from_docs_not_in_queue(self): + """iana and ref tags are removed from docs with rfceditor state not in the queue.""" + iana_tag = DocTagName.objects.get(slug="iana") + ref_tag = DocTagName.objects.get(slug="ref") + draft = WgDraftFactory(states=[("draft-rfceditor", "in_progress")]) + draft.tags.add(iana_tag, ref_tag) + + tasks.process_rpc_queue_task([]) + + draft = Document.objects.get(pk=draft.pk) + self.assertNotIn(iana_tag, draft.tags.all()) + self.assertNotIn(ref_tag, draft.tags.all()) + + def test_docs_in_queue_retain_rfceditor_state(self): + """Documents present in the queue keep their draft-rfceditor state.""" + draft = WgDraftFactory(states=[("draft-rfceditor", "in_progress")]) + + tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) + + draft = Document.objects.get(pk=draft.pk) + self.assertIsNotNone(draft.get_state("draft-rfceditor")) diff --git a/ietf/templates/doc/document_info.html b/ietf/templates/doc/document_info.html index d6d8d43071..1666c42ae5 100644 --- a/ietf/templates/doc/document_info.html +++ b/ietf/templates/doc/document_info.html @@ -97,7 +97,7 @@ {# Implementation that uses the current primary email for each author #} {% if doc.pk %}{% for author in doc.author_persons_or_names %} - {% if author.person %}{% person_link author.person %}{% else %}{{ author.titlepage_name }}{% endif %}{% if not forloop.last %},{% endif %} + {% if author.person %}{% person_link author.person titlepage_name=author.titlepage_name %}{% else %}{{ author.titlepage_name }}{% endif %}{% if not forloop.last %},{% endif %} {% endfor %}{% endif %} {% if document_html and not snapshot or document_html and doc.rev == latest_rev%}
diff --git a/ietf/templates/doc/draft/rfceditor_post_approved_draft_failed.html b/ietf/templates/doc/draft/rfceditor_post_approved_draft_failed.html deleted file mode 100644 index f976ead926..0000000000 --- a/ietf/templates/doc/draft/rfceditor_post_approved_draft_failed.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} -{% block title %}Posting approved I-D to RFC Editor failed{% endblock %} -{% block content %} - {% origin %} -

Posting approved I-D to RFC Editor failed

-

- Sorry, when trying to notify the RFC Editor through HTTP, we hit an - error. -

-

- We have not changed the Internet-Draft state or sent the announcement - yet so if this is an intermittent error, you can go back and try - again. -

-

- The error was: {{ error }} -

- {% if response %} -

- The response from the RFC Editor was: - {{ response|linebreaksbr }} -

- {% endif %} -{% endblock %} \ No newline at end of file diff --git a/ietf/templates/person/person_link.html b/ietf/templates/person/person_link.html index f3f7e1a5b7..b77fe8d6df 100644 --- a/ietf/templates/person/person_link.html +++ b/ietf/templates/person/person_link.html @@ -1,7 +1,7 @@ {% if email and email == "system@datatracker.ietf.org" or name and name == "(System)" %}(System){% else %}{% if email or name %}{{ name }}{% if email and with_email %} {% if titlepage_name %}{{ titlepage_name }}{% else %}{{ name }}{% endif %}{% if email and with_email %} diff --git a/ietf/utils/searchindex.py b/ietf/utils/searchindex.py index 87951abb60..a9e02ca740 100644 --- a/ietf/utils/searchindex.py +++ b/ietf/utils/searchindex.py @@ -5,8 +5,10 @@ from itertools import batched from math import floor from typing import Iterable +from urllib.parse import urljoin import httpx # just for exceptions +import requests import typesense import typesense.exceptions from django.conf import settings @@ -26,7 +28,6 @@ typesense.exceptions.ServiceUnavailable, ) - DEFAULT_SETTINGS = { "TYPESENSE_API_URL": "", "TYPESENSE_API_KEY": "", @@ -98,7 +99,10 @@ def typesense_doc_from_rfc(rfc: Document) -> DocumentSchema: ) subseries = subseries[0] if len(subseries) > 0 else None obsoleted_by = rfc.related_that("obs") + is_obsoleted = len(obsoleted_by) > 0 updated_by = rfc.related_that("updates") + is_updated = len(updated_by) > 0 + is_historic = rfc.std_level.slug == "hist" stored_txt = ( StoredObject.objects.exclude_deleted() @@ -133,9 +137,9 @@ def typesense_doc_from_rfc(rfc: Document) -> DocumentSchema: for rfc_author in rfc.rfcauthor_set.all() ], "flags": { - "hiddenDefault": False, - "obsoleted": len(obsoleted_by) > 0, - "updated": len(updated_by) > 0, + "hiddenDefault": is_obsoleted or is_historic, + "obsoleted": is_obsoleted, + "updated": is_updated, }, "obsoletedBy": [str(doc.rfc_number) for doc in obsoleted_by], "updatedBy": [str(doc.rfc_number) for doc in updated_by], @@ -144,7 +148,7 @@ def typesense_doc_from_rfc(rfc: Document) -> DocumentSchema: if subseries is not None: ts_document["subseries"] = { "acronym": subseries.type.slug, - "number": int(subseries.name[len(subseries.type.slug) :]), + "number": int(subseries.name[len(subseries.type.slug):]), "total": len(subseries.contains()), } if rfc.group is not None: @@ -354,6 +358,21 @@ def update_or_create_rfc_entries( ], } +SEARCH_PRESETS = { + "red": { + "collection": "docs", + "infix": "off,always,off,off,off,off,off,off", + "query_by": "rfc,filename,title,abstract,keywords,authors,group,area", + "query_by_weights": "127,50,50,20,20,5,2,1" + }, + "red-content": { + "collection": "docs", + "infix": "off,always,off,off", + "query_by": "rfc,filename,authors,content", + "query_by_weights": "127,50,5,1" + }, +} + def create_collection(): collection_name = get_collection_name() @@ -370,3 +389,21 @@ def delete_collection(): client.collections[collection_name].delete() except typesense.exceptions.ObjectNotFound: pass + + +def upsert_presets(): + # typesense-python does not support presets, so use requests + _settings = get_settings() + api_base = _settings["TYPESENSE_API_URL"] + api_key = _settings["TYPESENSE_API_KEY"] + for preset_name, payload in SEARCH_PRESETS.items(): + log(f"Upserting '{preset_name}' preset") + response = requests.put( + urljoin(api_base, f"/presets/{preset_name}"), + json={"value": payload}, + headers={ + "X-TYPESENSE-API-KEY": api_key, + }, + timeout=3, + ) + response.raise_for_status() diff --git a/ietf/utils/tests_searchindex.py b/ietf/utils/tests_searchindex.py index e9fbf52020..7eb4e8acea 100644 --- a/ietf/utils/tests_searchindex.py +++ b/ietf/utils/tests_searchindex.py @@ -1,6 +1,7 @@ # Copyright The IETF Trust 2026, All Rights Reserved from unittest import mock +import requests.exceptions import typesense.exceptions from django.conf import settings from django.test.utils import override_settings @@ -15,7 +16,7 @@ BcpFactory, StdFactory, ) -from ..doc.models import Document +from ..doc.models import Document, RelatedDocument from ..doc.storage_utils import store_str from ..person.factories import PersonFactory @@ -45,13 +46,6 @@ def test_sanitize_text(self): sanitized = "This is text It is full of problems Fix it." self.assertEqual(searchindex._sanitize_text(dirty_text), sanitized) - @override_settings( - SEARCHINDEX_CONFIG={ - "TYPESENSE_API_URL": "http://ts.example.com", - "TYPESENSE_API_KEY": "test-api-key", - "TYPESENSE_COLLECTION_NAME": "frogs", - } - ) def test_typesense_doc_from_rfc(self): not_rfc = WgDraftFactory() assert isinstance(not_rfc, Document) @@ -111,6 +105,60 @@ def test_typesense_doc_from_rfc(self): result = searchindex.typesense_doc_from_rfc(rfc) self.assertNotIn("content", result) + def test_typesense_doc_from_rfc_flags_obsoleted(self): + """typesense docs should set correct flags for obsoleted RFC""" + rfc = PublishedRfcDocEventFactory().doc + assert isinstance(rfc, Document) + self.assertEqual(len(rfc.related_that("obs")), 0) + self.assertEqual(len(rfc.related_that("updates")), 0) + self.assertNotEqual(rfc.std_level.slug, "hist") + result = searchindex.typesense_doc_from_rfc(rfc) + self.assertFalse(result["flags"]["hiddenDefault"]) + self.assertFalse(result["flags"]["obsoleted"]) + self.assertFalse(result["flags"]["updated"]) + + RelatedDocument.objects.create( + source=(PublishedRfcDocEventFactory().doc), + target=rfc, + relationship_id="obs", + ) + result = searchindex.typesense_doc_from_rfc(rfc) + self.assertTrue(result["flags"]["hiddenDefault"]) + self.assertTrue(result["flags"]["obsoleted"]) + self.assertFalse(result["flags"]["updated"]) + + def test_typesense_doc_from_rfc_flags_updated(self): + """typesense docs should set flags correctly for updated RFC""" + rfc = PublishedRfcDocEventFactory().doc + assert isinstance(rfc, Document) + self.assertEqual(len(rfc.related_that("obs")), 0) + self.assertEqual(len(rfc.related_that("updates")), 0) + self.assertNotEqual(rfc.std_level.slug, "hist") + result = searchindex.typesense_doc_from_rfc(rfc) + self.assertFalse(result["flags"]["hiddenDefault"]) + self.assertFalse(result["flags"]["obsoleted"]) + self.assertFalse(result["flags"]["updated"]) + + RelatedDocument.objects.create( + source=(PublishedRfcDocEventFactory().doc), + target=rfc, + relationship_id="updates", + ) + result = searchindex.typesense_doc_from_rfc(rfc) + self.assertFalse(result["flags"]["hiddenDefault"]) + self.assertFalse(result["flags"]["obsoleted"]) + self.assertTrue(result["flags"]["updated"]) + + def test_typesense_doc_from_rfc_flags_historic(self): + """typesense docs should set flags correctly for historic RFC""" + rfc = PublishedRfcDocEventFactory(doc__std_level_id="hist").doc + assert isinstance(rfc, Document) + result = searchindex.typesense_doc_from_rfc(rfc) + self.assertTrue(result["flags"]["hiddenDefault"]) + self.assertFalse(result["flags"]["obsoleted"]) + self.assertFalse(result["flags"]["updated"]) + + @override_settings( SEARCHINDEX_CONFIG={ "TYPESENSE_API_URL": "http://ts.example.com", @@ -211,3 +259,34 @@ def test_delete_collection(self, mock_ts_client_constructor): mock_collections["frogs"].side_effect = typesense.exceptions.ObjectNotFound searchindex.delete_collection() # should ignore the exception + + @override_settings( + SEARCHINDEX_CONFIG={ + "TYPESENSE_API_URL": "http://ts.example.com", + "TYPESENSE_API_KEY": "test-api-key", + "TYPESENSE_COLLECTION_NAME": "frogs", + } + ) + def test_upsert_presets(self): + self.requests_mock.put( + "http://ts.example.com/presets/red", text="ok", status_code=201 + ) + self.requests_mock.put( + "http://ts.example.com/presets/red-content", text="ok", status_code=202 + ) + searchindex.upsert_presets() + + self.requests_mock.put( + "http://ts.example.com/presets/red", text="not ok", status_code=400 + ) + with self.assertRaises(requests.exceptions.HTTPError): + searchindex.upsert_presets() + + self.requests_mock.put( + "http://ts.example.com/presets/red", text="ok", status_code=200 + ) + self.requests_mock.put( + "http://ts.example.com/presets/red-content", text="not ok", status_code=400 + ) + with self.assertRaises(requests.exceptions.HTTPError): + searchindex.upsert_presets() diff --git a/k8s/auth.yaml b/k8s/auth.yaml index 2bdb064447..992c90557a 100644 --- a/k8s/auth.yaml +++ b/k8s/auth.yaml @@ -9,7 +9,7 @@ spec: matchLabels: app: auth strategy: - type: Recreate + type: DEPLOY_STRATEGY template: metadata: labels: @@ -61,6 +61,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 1000 runAsGroup: 1000 + resources: + requests: + cpu: 250m + memory: 4Gi # ----------------------------------------------------- # Nginx Container # ----------------------------------------------------- @@ -86,6 +90,10 @@ spec: - name: dt-cfg mountPath: /etc/nginx/conf.d/default.conf subPath: nginx-auth.conf + resources: + requests: + cpu: 10m + memory: 10Mi # ----------------------------------------------------- # ScoutAPM Container # ----------------------------------------------------- @@ -111,6 +119,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 65534 # "nobody" user by default runAsGroup: 65534 # "nogroup" group by default + resources: + requests: + cpu: 10m + memory: 50Mi volumes: # To be overriden with the actual shared volume - name: dt-vol @@ -129,8 +141,6 @@ spec: - name: nginx-tmp emptyDir: sizeLimit: "500Mi" - dnsPolicy: ClusterFirst - restartPolicy: Always terminationGracePeriodSeconds: 60 --- apiVersion: v1 diff --git a/k8s/beat.yaml b/k8s/beat.yaml index b4291c7e31..cc171fb7d1 100644 --- a/k8s/beat.yaml +++ b/k8s/beat.yaml @@ -48,6 +48,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 1000 runAsGroup: 1000 + resources: + requests: + cpu: 100m + memory: 250Mi volumes: # To be overridden with the actual shared volume - name: dt-vol diff --git a/k8s/celery.yaml b/k8s/celery.yaml index 2f4c0fd439..f6cea2acc7 100644 --- a/k8s/celery.yaml +++ b/k8s/celery.yaml @@ -52,6 +52,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 1000 runAsGroup: 1000 + resources: + requests: + cpu: 100m + memory: 1Gi # ----------------------------------------------------- # ScoutAPM Container # ----------------------------------------------------- @@ -77,6 +81,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 65534 # "nobody" user by default runAsGroup: 65534 # "nogroup" group by default + resources: + requests: + cpu: 10m + memory: 50Mi volumes: # To be overridden with the actual shared volume - name: dt-vol diff --git a/k8s/datatracker.yaml b/k8s/datatracker.yaml index 50a2c69687..ff89fb3722 100644 --- a/k8s/datatracker.yaml +++ b/k8s/datatracker.yaml @@ -9,7 +9,7 @@ spec: matchLabels: app: datatracker strategy: - type: Recreate + type: DEPLOY_STRATEGY template: metadata: labels: @@ -61,6 +61,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 1000 runAsGroup: 1000 + resources: + requests: + cpu: 250m + memory: 4Gi # ----------------------------------------------------- # Nginx Container # ----------------------------------------------------- @@ -87,6 +91,10 @@ spec: # Replaces the original default.conf mountPath: /etc/nginx/conf.d/default.conf subPath: nginx-datatracker.conf + resources: + requests: + cpu: 10m + memory: 10Mi # ----------------------------------------------------- # ScoutAPM Container # ----------------------------------------------------- @@ -112,6 +120,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 65534 # "nobody" user by default runAsGroup: 65534 # "nogroup" group by default + resources: + requests: + cpu: 10m + memory: 50Mi initContainers: - name: migration image: "ghcr.io/ietf-tools/datatracker:$APP_IMAGE_TAG" @@ -160,8 +172,6 @@ spec: - name: nginx-tmp emptyDir: sizeLimit: "500Mi" - dnsPolicy: ClusterFirst - restartPolicy: Always terminationGracePeriodSeconds: 60 --- apiVersion: v1 diff --git a/k8s/memcached.yaml b/k8s/memcached.yaml index 5a4c9f0aed..68b732d745 100644 --- a/k8s/memcached.yaml +++ b/k8s/memcached.yaml @@ -36,6 +36,10 @@ spec: # memcached image sets up uid/gid 11211 runAsUser: 11211 runAsGroup: 11211 + resources: + requests: + cpu: 100m + memory: 100Mi # ----------------------------------------------------- # Memcached Exporter for Prometheus # ----------------------------------------------------- @@ -54,6 +58,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 65534 # nobody runAsGroup: 65534 # nobody + resources: + requests: + cpu: 10m + memory: 20Mi dnsPolicy: ClusterFirst restartPolicy: Always terminationGracePeriodSeconds: 30 diff --git a/k8s/rabbitmq.yaml b/k8s/rabbitmq.yaml index 346b54c93e..e69aa7a1aa 100644 --- a/k8s/rabbitmq.yaml +++ b/k8s/rabbitmq.yaml @@ -62,6 +62,10 @@ spec: # rabbitmq image sets up uid/gid 100/101 runAsUser: 100 runAsGroup: 101 + resources: + requests: + cpu: 100m + memory: 150Mi initContainers: # ----------------------------------------------------- # Init RabbitMQ data diff --git a/k8s/replicator.yaml b/k8s/replicator.yaml index a28d9e8a16..0b06fe4fdc 100644 --- a/k8s/replicator.yaml +++ b/k8s/replicator.yaml @@ -52,6 +52,10 @@ spec: readOnlyRootFilesystem: true runAsUser: 1000 runAsGroup: 1000 + resources: + requests: + cpu: 100m + memory: 500Mi volumes: # To be overridden with the actual shared volume - name: dt-vol diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml index ba90af9c2a..4d32652146 100644 --- a/k8s/secrets.yaml +++ b/k8s/secrets.yaml @@ -39,7 +39,6 @@ stringData: DATATRACKER_NOMCOM_APP_SECRET_B64: "m9pzMezVoFNJfsvU9XSZxGnXnwup6P5ZgCQeEnROOoQ=" # secret DATATRACKER_IANA_SYNC_PASSWORD: "this-is-the-iana-sync-password" # secret - DATATRACKER_RFC_EDITOR_SYNC_PASSWORD: "this-is-the-rfc-editor-sync-password" # secret DATATRACKER_YOUTUBE_API_KEY: "this-is-the-youtube-api-key" # secret DATATRACKER_GITHUB_BACKUP_API_KEY: "this-is-the-github-backup-api-key" # secret @@ -80,4 +79,4 @@ stringData: # Scout configuration DATATRACKER_SCOUT_KEY: "this-is-the-scout-key" - DATATRACKER_SCOUT_NAME: "StagingDatatracker" \ No newline at end of file + DATATRACKER_SCOUT_NAME: "StagingDatatracker" diff --git a/k8s/settings_local.py b/k8s/settings_local.py index 19d0a1c2f5..402f89787b 100644 --- a/k8s/settings_local.py +++ b/k8s/settings_local.py @@ -44,12 +44,6 @@ def _multiline_to_list(s): else: raise RuntimeError("DATATRACKER_IANA_SYNC_PASSWORD must be set") -_RFC_EDITOR_SYNC_PASSWORD = os.environ.get("DATATRACKER_RFC_EDITOR_SYNC_PASSWORD", None) -if _RFC_EDITOR_SYNC_PASSWORD is not None: - RFC_EDITOR_SYNC_PASSWORD = os.environ.get("DATATRACKER_RFC_EDITOR_SYNC_PASSWORD") -else: - raise RuntimeError("DATATRACKER_RFC_EDITOR_SYNC_PASSWORD must be set") - _YOUTUBE_API_KEY = os.environ.get("DATATRACKER_YOUTUBE_API_KEY", None) if _YOUTUBE_API_KEY is not None: YOUTUBE_API_KEY = _YOUTUBE_API_KEY @@ -496,9 +490,11 @@ def _multiline_to_list(s): SEARCHINDEX_CONFIG = { "TYPESENSE_API_URL": os.environ.get("DATATRACKER_TYPESENSE_API_URL", ""), "TYPESENSE_API_KEY": os.environ.get("DATATRACKER_TYPESENSE_API_KEY", ""), - "TASK_RETRY_DELAY": os.environ.get("DATATRACKER_SEARCHINDEX_TASK_RETRY_DELAY", 10), - "TASK_MAX_RETRIES": os.environ.get( - "DATATRACKER_SEARCHINDEX_TASK_MAX_RETRIES", "12" + "TASK_RETRY_DELAY": int( + os.environ.get("DATATRACKER_SEARCHINDEX_TASK_RETRY_DELAY", "10") + ), + "TASK_MAX_RETRIES": int( + os.environ.get("DATATRACKER_SEARCHINDEX_TASK_MAX_RETRIES", "12") ), } diff --git a/patch/django-cookie-delete-with-all-settings.patch b/patch/django-cookie-delete-settings-and-CVE-2026-35192.patch similarity index 68% rename from patch/django-cookie-delete-with-all-settings.patch rename to patch/django-cookie-delete-settings-and-CVE-2026-35192.patch index 4ceaf8fceb..3f625c33bb 100644 --- a/patch/django-cookie-delete-with-all-settings.patch +++ b/patch/django-cookie-delete-settings-and-CVE-2026-35192.patch @@ -44,9 +44,9 @@ expires="Thu, 01 Jan 1970 00:00:00 GMT", samesite=samesite, ) ---- django/contrib/sessions/middleware.py.orig 2020-08-13 12:12:12.401898114 +0200 -+++ django/contrib/sessions/middleware.py 2020-08-13 12:14:52.690520659 +0200 -@@ -38,6 +38,8 @@ +--- django/contrib/sessions/middleware.py.old 2026-05-12 15:18:07.673997003 +0000 ++++ django/contrib/sessions/middleware.py 2026-05-12 15:18:15.770997007 +0000 +@@ -38,12 +38,15 @@ settings.SESSION_COOKIE_NAME, path=settings.SESSION_COOKIE_PATH, domain=settings.SESSION_COOKIE_DOMAIN, @@ -54,4 +54,23 @@ + httponly=settings.SESSION_COOKIE_HTTPONLY or None, samesite=settings.SESSION_COOKIE_SAMESITE, ) - patch_vary_headers(response, ("Cookie",)) +- patch_vary_headers(response, ("Cookie",)) ++ need_vary_cookie = True + else: +- if accessed: +- patch_vary_headers(response, ("Cookie",)) ++ # If the session was accessed, it must be varied on, regardless of ++ # whether it was modified or will be saved. ++ need_vary_cookie = accessed + if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: + if request.session.get_expire_at_browser_close(): + max_age = None +@@ -74,4 +77,8 @@ + httponly=settings.SESSION_COOKIE_HTTPONLY or None, + samesite=settings.SESSION_COOKIE_SAMESITE, + ) ++ # With a session cookie set, it must be varied on. ++ need_vary_cookie = True ++ if need_vary_cookie: ++ patch_vary_headers(response, ("Cookie",)) + return response diff --git a/requirements.txt b/requirements.txt index ca9a6740e1..31e8ea69d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ botocore>=1.39.15 celery>=5.5.3 coverage>=7.9.2 defusedxml>=0.7.1 # for TastyPie when using xml; not a declared dependency -Django>4.2,<5 +Django>=4.2.30,<5 django-admin-rangefilter>=0.13.3 django-analytical>=3.2.0 django-bootstrap5>=25.1 @@ -90,6 +90,6 @@ unidecode>=1.4.0 urllib3>=2.5.0 weasyprint>=66.0 xml2rfc>=3.30.0 -xym>=0.6,<1.0 +xym>=0.6,<0.10.0 zxcvbn>=4.5.0 types-zxcvbn~=4.5.0.20250223 # match zxcvbn version