Skip to content

Commit 3203f91

Browse files
chore: merge main to release
2 parents 0e16a74 + adbaaa5 commit 3203f91

36 files changed

Lines changed: 1313 additions & 55 deletions

.github/workflows/build.yml

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -418,17 +418,14 @@ jobs:
418418
echo "DEPLOY_NAMESPACE=$(node cli.js --branch ${{ github.ref_name }})" >> "$GITHUB_ENV"
419419
420420
- name: Deploy to dev
421-
uses: the-actions-org/workflow-dispatch@v4
421+
uses: ietf-tools/workflow-dispatch-action@v1
422422
with:
423423
workflow: deploy-dev.yml
424424
repo: ietf-tools/infra-k8s
425425
ref: main
426426
token: ${{ secrets.GH_INFRA_K8S_TOKEN }}
427427
inputs: '{ "app":"datatracker", "appVersion":"${{ env.PKG_VERSION }}", "remoteRef":"${{ github.sha }}", "namespace":"${{ env.DEPLOY_NAMESPACE }}", "disableDailyDbRefresh":${{ inputs.devNoDbRefresh }} }'
428-
wait-for-completion: true
429-
wait-for-completion-timeout: 60m
430-
wait-for-completion-interval: 30s
431-
display-workflow-run-url: false
428+
waitForCompletionTimeout: 60m
432429

433430
# -----------------------------------------------------------------
434431
# STAGING
@@ -445,30 +442,24 @@ jobs:
445442

446443
steps:
447444
- name: Refresh Staging DB
448-
uses: the-actions-org/workflow-dispatch@v4
445+
uses: ietf-tools/workflow-dispatch-action@v1
449446
with:
450447
workflow: deploy-db.yml
451448
repo: ietf-tools/infra-k8s
452449
ref: main
453450
token: ${{ secrets.GH_INFRA_K8S_TOKEN }}
454451
inputs: '{ "environment":"${{ secrets.GHA_K8S_CLUSTER }}", "app":"datatracker", "manifest":"postgres", "forceRecreate":true, "restoreToLastFullSnapshot":true, "waitClusterReady":true }'
455-
wait-for-completion: true
456-
wait-for-completion-timeout: 120m
457-
wait-for-completion-interval: 20s
458-
display-workflow-run-url: false
452+
waitForCompletionTimeout: 120m
459453

460454
- name: Deploy to staging
461-
uses: the-actions-org/workflow-dispatch@v4
455+
uses: ietf-tools/workflow-dispatch-action@v1
462456
with:
463457
workflow: deploy.yml
464458
repo: ietf-tools/infra-k8s
465459
ref: main
466460
token: ${{ secrets.GH_INFRA_K8S_TOKEN }}
467461
inputs: '{ "environment":"${{ secrets.GHA_K8S_CLUSTER }}", "app":"datatracker", "appVersion":"${{ env.PKG_VERSION }}", "remoteRef":"${{ github.sha }}" }'
468-
wait-for-completion: true
469-
wait-for-completion-timeout: 30m
470-
wait-for-completion-interval: 30s
471-
display-workflow-run-url: false
462+
waitForCompletionTimeout: 30m
472463

473464
# -----------------------------------------------------------------
474465
# PROD
@@ -485,14 +476,11 @@ jobs:
485476

486477
steps:
487478
- name: Deploy to production
488-
uses: the-actions-org/workflow-dispatch@v4
479+
uses: ietf-tools/workflow-dispatch-action@v1
489480
with:
490481
workflow: deploy.yml
491482
repo: ietf-tools/infra-k8s
492483
ref: main
493484
token: ${{ secrets.GH_INFRA_K8S_TOKEN }}
494485
inputs: '{ "environment":"${{ secrets.GHA_K8S_CLUSTER }}", "app":"datatracker", "appVersion":"${{ env.PKG_VERSION }}", "remoteRef":"${{ github.sha }}" }'
495-
wait-for-completion: true
496-
wait-for-completion-timeout: 30m
497-
wait-for-completion-interval: 30s
498-
display-workflow-run-url: false
486+
waitForCompletionTimeout: 30m

.vscode/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,8 @@
5757
"python.testing.pytestEnabled": false,
5858
"python.testing.unittestEnabled": false,
5959
"python.linting.enabled": true,
60-
"python.terminal.shellIntegration.enabled": false
60+
"python.terminal.shellIntegration.enabled": false,
61+
"vs-kubernetes": {
62+
"disable-linters": ["resource-limits"]
63+
}
6164
}

dev/build/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM ghcr.io/ietf-tools/datatracker-app-base:20260410T1557
1+
FROM ghcr.io/ietf-tools/datatracker-app-base:20260512T1629
22
LABEL maintainer="IETF Tools Team <tools-discuss@ietf.org>"
33

44
ENV DEBIAN_FRONTEND=noninteractive

dev/build/TARGET_BASE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
20260410T1557
1+
20260512T1629

ietf/api/serializers_rpc.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.utils import timezone
99
from drf_spectacular.types import OpenApiTypes
1010
from drf_spectacular.utils import extend_schema_field
11-
from rest_framework import serializers
11+
from rest_framework import fields, serializers
1212

1313
from ietf.doc.expire import move_draft_files_to_archive
1414
from ietf.doc.models import (
@@ -265,6 +265,23 @@ def __init__(self, **kwargs):
265265
super().__init__(regex, **kwargs)
266266

267267

268+
class RfcGroupRelatedField(serializers.SlugRelatedField):
269+
"""SlugRelatedField that translates None / "" to the acronym "none" """
270+
271+
def __init__(self, **kwargs):
272+
super().__init__(
273+
slug_field="acronym",
274+
queryset=Group.objects.all(),
275+
allow_null=True,
276+
required=False,
277+
)
278+
279+
def run_validation(self, data=fields.empty):
280+
# Use the Group with acronym "none" when group is not specified
281+
if data is fields.empty or data is None or data == "":
282+
data = "none"
283+
return super().run_validation(data)
284+
268285

269286
class RfcPubSerializer(serializers.ModelSerializer):
270287
"""Write-only serializer for RFC publication"""
@@ -279,9 +296,7 @@ class RfcPubSerializer(serializers.ModelSerializer):
279296

280297
# fields on the RFC Document that need tweaking from ModelSerializer defaults
281298
rfc_number = serializers.IntegerField(min_value=1, required=True)
282-
group = serializers.SlugRelatedField(
283-
slug_field="acronym", queryset=Group.objects.all(), required=False
284-
)
299+
group = RfcGroupRelatedField()
285300
stream = serializers.PrimaryKeyRelatedField(
286301
queryset=StreamName.objects.filter(used=True)
287302
)
@@ -556,6 +571,18 @@ class EditableRfcSerializer(serializers.ModelSerializer):
556571
child=SubseriesNameField(required=False),
557572
write_only=True,
558573
)
574+
updates = serializers.ListField(
575+
child=serializers.IntegerField(),
576+
required=False,
577+
write_only=True,
578+
help_text="List of RFC numbers this document updates."
579+
)
580+
obsoletes = serializers.ListField(
581+
child=serializers.IntegerField(),
582+
required=False,
583+
write_only=True,
584+
help_text="List of RFC numbers this document obsoletes."
585+
)
559586

560587
class Meta:
561588
model = Document
@@ -569,7 +596,27 @@ class Meta:
569596
"std_level",
570597
"subseries",
571598
"keywords",
599+
"updates",
600+
"obsoletes",
601+
]
602+
603+
def _validate_rfc_number_list(self, field_name, rfc_numbers):
604+
"""Raise ValidationError if any RFC numbers in the list don't exist."""
605+
unknown = [
606+
n for n in rfc_numbers
607+
if not Document.objects.filter(rfc_number=n, type_id="rfc").exists()
572608
]
609+
if unknown:
610+
raise serializers.ValidationError(
611+
{field_name: [f"Unknown RFC number: {n}" for n in unknown]}
612+
)
613+
return rfc_numbers
614+
615+
def validate_updates(self, value):
616+
return self._validate_rfc_number_list("updates", value)
617+
618+
def validate_obsoletes(self, value):
619+
return self._validate_rfc_number_list("obsoletes", value)
573620

574621
def create(self, validated_data):
575622
raise RuntimeError("Cannot create with this serializer")
@@ -587,6 +634,8 @@ def update(self, instance, validated_data):
587634
published = validated_data.pop("published", omitted)
588635
subseries = validated_data.pop("subseries", omitted)
589636
authors_data = validated_data.pop("rfcauthor_set", omitted)
637+
updates = validated_data.pop("updates", omitted)
638+
obsoletes = validated_data.pop("obsoletes", omitted)
590639

591640
# Transaction to clean up if something fails
592641
with transaction.atomic():
@@ -658,6 +707,24 @@ def update(self, instance, validated_data):
658707
)
659708
)
660709
)
710+
if updates is not omitted:
711+
RelatedDocument.objects.filter(
712+
source=rfc, relationship_id="updates"
713+
).exclude(target__rfc_number__in=updates).delete()
714+
for rfc_num in updates:
715+
target = Document.objects.get(rfc_number=rfc_num, type_id="rfc")
716+
RelatedDocument.objects.get_or_create(
717+
source=rfc, relationship_id="updates", target=target
718+
)
719+
if obsoletes is not omitted:
720+
RelatedDocument.objects.filter(
721+
source=rfc, relationship_id="obs"
722+
).exclude(target__rfc_number__in=obsoletes).delete()
723+
for rfc_num in obsoletes:
724+
target = Document.objects.get(rfc_number=rfc_num, type_id="rfc")
725+
RelatedDocument.objects.get_or_create(
726+
source=rfc, relationship_id="obs", target=target
727+
)
661728

662729
# update subseries relations
663730
if subseries is not omitted:

ietf/api/tests_serializers_rpc.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,27 @@ def test_partial_update(self, mock_trigger_red_task, mock_update_searchindex_tas
215215
mock_update_searchindex_task.delay.call_args,
216216
mock.call(rfc.rfc_number),
217217
)
218+
219+
def test_unknown_rfc_number_rejected(self):
220+
"""Unknown RFC numbers in updates/obsoletes should cause validation failure."""
221+
from django.db.models import Max
222+
223+
rfc = WgRfcFactory()
224+
unknown_rfc_number = (
225+
Document.objects.filter(rfc_number__isnull=False).aggregate(
226+
m=Max("rfc_number") + 1
227+
)["m"]
228+
or 10000
229+
)
230+
231+
for field in ("updates", "obsoletes"):
232+
serializer = EditableRfcSerializer(
233+
partial=True,
234+
instance=rfc,
235+
data={field: [unknown_rfc_number]},
236+
)
237+
self.assertFalse(
238+
serializer.is_valid(),
239+
msg=f"{field} with unknown RFC number should be invalid",
240+
)
241+
self.assertIn(field, serializer.errors)

ietf/api/tests_views_rpc.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,3 +477,91 @@ def test_destination_helper_mixin_blob_destination(self):
477477
DestinationHelperMixin().blob_destination(filename),
478478
f"notprepped/{filename}",
479479
)
480+
481+
@override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]})
482+
@mock.patch("ietf.api.views_rpc.process_rpc_queue_task.delay")
483+
def test_process_rpc_queue(self, mock_task_delay):
484+
url = urlreverse("ietf.api.purple_api.process_rpc_queue")
485+
queue_entries = [
486+
{
487+
"id": 9850,
488+
"name": "draft-ietf-netmod-system-config",
489+
"title": "System-defined Configuration",
490+
"draft_url": "http://localhost:8000/doc/draft-ietf-netmod-system-config-20",
491+
"disposition": "in_progress",
492+
"external_deadline": None,
493+
"labels": [],
494+
"cluster": None,
495+
"assignment_set": [
496+
{
497+
"id": 434,
498+
"rfc_to_be": 9850,
499+
"role": "first_editor",
500+
"state": "in_progress",
501+
}
502+
],
503+
"actionholder_set": [],
504+
"pending_activities": [],
505+
"rfc_number": None,
506+
"pages": 33,
507+
"enqueued_at": "2026-01-26T12:00:00Z",
508+
"final_approval": [],
509+
"iana_status": {
510+
"slug": "completed",
511+
"name": "completed",
512+
"desc": "IANA has completed actions in draft",
513+
},
514+
"blocking_reasons": [],
515+
"authors": [{"titlepage_name": "Q. Ma", "is_editor": True}],
516+
"approval_log_message": [],
517+
"stream": "ietf",
518+
"group": "netmod",
519+
"group_name": "Network Modeling",
520+
"std_level": "ps",
521+
"references": [],
522+
"rev": "20",
523+
}
524+
]
525+
queue_data = {"data": queue_entries}
526+
527+
# no credentials
528+
response = self.client.post(
529+
url, data=queue_data, content_type="application/json"
530+
)
531+
self.assertEqual(response.status_code, 403)
532+
mock_task_delay.assert_not_called()
533+
534+
# invalid token
535+
response = self.client.post(
536+
url,
537+
data=queue_data,
538+
content_type="application/json",
539+
headers={"X-Api-Key": "invalid-token"},
540+
)
541+
self.assertEqual(response.status_code, 403)
542+
mock_task_delay.assert_not_called()
543+
544+
# valid token, wrong method
545+
response = self.client.get(url, headers={"X-Api-Key": "valid-token"})
546+
self.assertEqual(response.status_code, 405)
547+
mock_task_delay.assert_not_called()
548+
549+
# valid token, missing "data" field
550+
response = self.client.post(
551+
url,
552+
data={},
553+
content_type="application/json",
554+
headers={"X-Api-Key": "valid-token"},
555+
)
556+
self.assertEqual(response.status_code, 400)
557+
mock_task_delay.assert_not_called()
558+
559+
# valid token, POST with data
560+
response = self.client.post(
561+
url,
562+
data=queue_data,
563+
content_type="application/json",
564+
headers={"X-Api-Key": "valid-token"},
565+
)
566+
self.assertEqual(response.status_code, 202)
567+
mock_task_delay.assert_called_once_with(queue_entries)

ietf/api/urls_rpc.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
name="ietf.api.purple_api.refresh_rfc_index",
3737
),
3838
path(r"subject/<str:subject_id>/person/", views_rpc.SubjectPersonView.as_view()),
39+
path(
40+
r"queue/process/",
41+
views_rpc.ProcessRpcQueueView.as_view(),
42+
name="ietf.api.purple_api.process_rpc_queue",
43+
),
3944
]
4045

4146
# add routers at the end so individual routes can steal parts of their address

ietf/api/views_rpc.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
)
4949
from ietf.person.models import Email, Person
5050
from ietf.sync.rfcindex import mark_rfcindex_as_dirty
51+
from ietf.sync.tasks import process_rpc_queue_task
5152

5253

5354
class Conflict(APIException):
@@ -576,3 +577,24 @@ class RfcIndexView(APIView):
576577
def post(self, request):
577578
mark_rfcindex_as_dirty()
578579
return Response(status=202)
580+
581+
582+
class RpcQueueDataSerializer(serializers.Serializer):
583+
data = serializers.JSONField()
584+
585+
586+
class ProcessRpcQueueView(APIView):
587+
api_key_endpoint = "ietf.api.views_rpc"
588+
589+
@extend_schema(
590+
operation_id="process_rpc_queue",
591+
summary="Process the provided RPC queue",
592+
description="Schedules parsing the provided queue to update documents with change dqueue data",
593+
responses={202: None},
594+
request=RpcQueueDataSerializer,
595+
)
596+
def post(self, request):
597+
serializer = RpcQueueDataSerializer(data=request.data)
598+
serializer.is_valid(raise_exception=True)
599+
process_rpc_queue_task.delay(serializer.validated_data["data"])
600+
return Response(status=202)

0 commit comments

Comments
 (0)