Skip to content

Commit 0523c44

Browse files
chore: merge main into release
2 parents d3890c7 + 8dccf38 commit 0523c44

33 files changed

Lines changed: 930 additions & 178 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.DS_store
22
datatracker.sublime-project
33
datatracker.sublime-workspace
4+
/.claude
45
/.coverage
56
/.factoryboy_random_state
67
/.mypy_cache

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:20260408T1908
1+
FROM ghcr.io/ietf-tools/datatracker-app-base:20260410T1557
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-
20260408T1908
1+
20260410T1557

ietf/api/tests_views_rpc.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Copyright The IETF Trust 2025, All Rights Reserved
2+
import datetime
23
from io import StringIO
34
from pathlib import Path
45
from tempfile import TemporaryDirectory
@@ -10,12 +11,15 @@
1011
from django.test.utils import override_settings
1112
from django.urls import reverse as urlreverse
1213
import mock
14+
from django.utils import timezone
1315

1416
from ietf.blobdb.models import Blob
1517
from ietf.doc.factories import IndividualDraftFactory, RfcFactory, WgDraftFactory, WgRfcFactory
1618
from ietf.doc.models import RelatedDocument, Document
1719
from ietf.group.factories import RoleFactory, GroupFactory
1820
from ietf.person.factories import PersonFactory
21+
from ietf.sync.rfcindex import rfcindex_is_dirty
22+
from ietf.utils.models import DirtyBits
1923
from ietf.utils.test_utils import APITestCase, reload_db_objects
2024

2125

@@ -408,16 +412,21 @@ def _valid_post_data():
408412
)
409413

410414
@override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]})
411-
@mock.patch("ietf.api.views_rpc.create_rfc_index_task")
412-
def test_refresh_rfc_index(self, mock_task):
415+
def test_refresh_rfc_index(self):
416+
DirtyBits.objects.create(
417+
slug=DirtyBits.Slugs.RFCINDEX,
418+
dirty_time=timezone.now() - datetime.timedelta(days=1),
419+
processed_time=timezone.now() - datetime.timedelta(hours=12),
420+
)
421+
self.assertFalse(rfcindex_is_dirty())
413422
url = urlreverse("ietf.api.purple_api.refresh_rfc_index")
414423
response = self.client.get(url)
415424
self.assertEqual(response.status_code, 403)
416425
response = self.client.get(url, headers={"X-Api-Key": "invalid-token"})
417426
self.assertEqual(response.status_code, 403)
418427
response = self.client.get(url, headers={"X-Api-Key": "valid-token"})
419428
self.assertEqual(response.status_code, 405)
420-
self.assertFalse(mock_task.delay.called)
429+
self.assertFalse(rfcindex_is_dirty())
421430
response = self.client.post(url, headers={"X-Api-Key": "valid-token"})
422431
self.assertEqual(response.status_code, 202)
423-
self.assertTrue(mock_task.delay.called)
432+
self.assertTrue(rfcindex_is_dirty())

ietf/api/views_rpc.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
EmailPersonSerializer,
3333
RfcWithAuthorsSerializer,
3434
DraftWithAuthorsSerializer,
35-
NotificationAckSerializer, RfcPubSerializer, RfcFileSerializer,
35+
NotificationAckSerializer,
36+
RfcPubSerializer,
37+
RfcFileSerializer,
3638
EditableRfcSerializer,
3739
)
3840
from ietf.doc.models import Document, DocHistory, RfcAuthor, DocEvent
@@ -45,7 +47,7 @@
4547
update_rfc_searchindex_task,
4648
)
4749
from ietf.person.models import Email, Person
48-
from ietf.sync.tasks import create_rfc_index_task
50+
from ietf.sync.rfcindex import mark_rfcindex_as_dirty
4951

5052

5153
class Conflict(APIException):
@@ -344,9 +346,10 @@ def post(self, request):
344346

345347
class RfcAuthorViewSet(viewsets.ReadOnlyModelViewSet):
346348
"""ViewSet for RfcAuthor model
347-
349+
348350
Router needs to provide rfc_number as a kwarg
349351
"""
352+
350353
api_key_endpoint = "ietf.api.views_rpc"
351354

352355
queryset = RfcAuthor.objects.all()
@@ -407,7 +410,7 @@ class RfcPubFilesView(APIView):
407410

408411
def _fs_destination(self, filename: str | Path) -> Path:
409412
"""Destination for an uploaded RFC file in the filesystem
410-
413+
411414
Strips any path components in filename and returns an absolute Path.
412415
"""
413416
rfc_path = Path(settings.RFC_PATH)
@@ -419,7 +422,7 @@ def _fs_destination(self, filename: str | Path) -> Path:
419422

420423
def _blob_destination(self, filename: str | Path) -> str:
421424
"""Destination name for an uploaded RFC file in the blob store
422-
425+
423426
Strips any path components in filename and returns an absolute Path.
424427
"""
425428
filename = Path(filename) # could potentially have directory components
@@ -472,9 +475,7 @@ def post(self, request):
472475
code="files-exist",
473476
)
474477
for possible_existing_blob in possible_rfc_blobs:
475-
if exists_in_storage(
476-
kind=blob_kind, name=possible_existing_blob
477-
):
478+
if exists_in_storage(kind=blob_kind, name=possible_existing_blob):
478479
raise Conflict(
479480
"Blob(s) already exist for this RFC",
480481
code="blobs-exist",
@@ -523,7 +524,9 @@ def post(self, request):
523524

524525
# Trigger red precomputer
525526
needs_updating = [rfc.rfc_number]
526-
for rel in rfc.relateddocument_set.filter(relationship_id__in=["obs","updates"]):
527+
for rel in rfc.relateddocument_set.filter(
528+
relationship_id__in=["obs", "updates"]
529+
):
527530
needs_updating.append(rel.target.rfc_number)
528531
trigger_red_precomputer_task.delay(rfc_number_list=sorted(needs_updating))
529532
# Trigger search index update
@@ -540,10 +543,10 @@ class RfcIndexView(APIView):
540543
@extend_schema(
541544
operation_id="refresh_rfc_index",
542545
summary="Refresh rfc-index files",
543-
description="Requests creation of rfc-index.xml and rfc-index.txt files",
546+
description="Requests creation of various index files.",
544547
responses={202: None},
545548
request=None,
546549
)
547550
def post(self, request):
548-
create_rfc_index_task.delay()
551+
mark_rfcindex_as_dirty()
549552
return Response(status=202)

ietf/doc/feeds.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import datetime
66
import unicodedata
77

8+
from django.conf import settings
89
from django.contrib.syndication.views import Feed, FeedDoesNotExist
910
from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed
1011
from django.urls import reverse as urlreverse
@@ -223,7 +224,7 @@ def item_extra_kwargs(self, item):
223224
extra.update({"dcterms_accessRights": "gratis"})
224225
extra.update({"dcterms_format": "text/html"})
225226
media_contents = []
226-
if item.rfc_number < 8650:
227+
if item.rfc_number < settings.FIRST_V3_RFC:
227228
if item.rfc_number not in [8, 9, 51, 418, 500, 530, 589]:
228229
for fmt, media_type in [("txt", "text/plain"), ("html", "text/html")]:
229230
media_contents.append(

ietf/doc/storage_utils.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,7 @@ def retrieve_bytes(kind: str, name: str) -> bytes:
178178
content = f.read()
179179
except Exception as err:
180180
log(f"Blobstore Error: Failed to read bytes from {kind}:{name}: {repr(err)}")
181-
if settings.SERVER_MODE == "development":
182-
raise
181+
raise
183182
return content
184183

185184

@@ -192,6 +191,5 @@ def retrieve_str(kind: str, name: str) -> str:
192191
content = content_bytes.decode("utf-8")
193192
except Exception as err:
194193
log(f"Blobstore Error: Failed to read string from {kind}:{name}: {repr(err)}")
195-
if settings.SERVER_MODE == "development":
196-
raise
194+
raise
197195
return content

ietf/doc/tests_notprepped.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright The IETF Trust 2026, All Rights Reserved
2+
3+
from django.conf import settings
4+
from django.utils import timezone
5+
from django.urls import reverse as urlreverse
6+
7+
from pyquery import PyQuery
8+
9+
from ietf.doc.factories import WgRfcFactory
10+
from ietf.doc.models import StoredObject
11+
from ietf.doc.storage_utils import store_bytes
12+
from ietf.utils.test_utils import TestCase
13+
14+
15+
class NotpreppedRfcXmlTests(TestCase):
16+
def test_editor_source_button_visibility(self):
17+
pre_v3 = WgRfcFactory(rfc_number=settings.FIRST_V3_RFC - 1)
18+
first_v3 = WgRfcFactory(rfc_number=settings.FIRST_V3_RFC)
19+
post_v3 = WgRfcFactory(rfc_number=settings.FIRST_V3_RFC + 1)
20+
21+
for rfc, expect_button in [(pre_v3, False), (first_v3, True), (post_v3, True)]:
22+
r = self.client.get(
23+
urlreverse(
24+
"ietf.doc.views_doc.document_main", kwargs=dict(name=rfc.name)
25+
)
26+
)
27+
self.assertEqual(r.status_code, 200)
28+
buttons = PyQuery(r.content)('a.btn:contains("Get editor source")')
29+
if expect_button:
30+
self.assertEqual(len(buttons), 1, msg=f"rfc_number={rfc.rfc_number}")
31+
expected_href = urlreverse(
32+
"ietf.doc.views_doc.rfcxml_notprepped_wrapper",
33+
kwargs=dict(number=rfc.rfc_number),
34+
)
35+
self.assertEqual(
36+
buttons.attr("href"),
37+
expected_href,
38+
msg=f"rfc_number={rfc.rfc_number}",
39+
)
40+
else:
41+
self.assertEqual(len(buttons), 0, msg=f"rfc_number={rfc.rfc_number}")
42+
43+
def test_rfcxml_notprepped(self):
44+
number = settings.FIRST_V3_RFC
45+
stored_name = f"notprepped/rfc{number}.notprepped.xml"
46+
url = f"/doc/rfc{number}/notprepped/"
47+
48+
# 404 for pre-v3 RFC numbers (no document needed)
49+
r = self.client.get(f"/doc/rfc{number - 1}/notprepped/")
50+
self.assertEqual(r.status_code, 404)
51+
52+
# 404 when no RFC document exists in the database
53+
r = self.client.get(url)
54+
self.assertEqual(r.status_code, 404)
55+
56+
# 404 when RFC document exists but has no StoredObject
57+
WgRfcFactory(rfc_number=number)
58+
r = self.client.get(url)
59+
self.assertEqual(r.status_code, 404)
60+
61+
# 404 when StoredObject exists but backing storage is missing (FileNotFoundError)
62+
now = timezone.now()
63+
StoredObject.objects.create(
64+
store="rfc",
65+
name=stored_name,
66+
sha384="a" * 96,
67+
len=0,
68+
store_created=now,
69+
created=now,
70+
modified=now,
71+
)
72+
r = self.client.get(url)
73+
self.assertEqual(r.status_code, 404)
74+
75+
# 200 with correct content-type, attachment disposition, and body when object is fully stored
76+
xml_content = b"<rfc>test</rfc>"
77+
store_bytes("rfc", stored_name, xml_content, allow_overwrite=True)
78+
r = self.client.get(url)
79+
self.assertEqual(r.status_code, 200)
80+
self.assertEqual(r["Content-Type"], "application/xml")
81+
self.assertEqual(
82+
r["Content-Disposition"],
83+
f'attachment; filename="rfc{number}.notprepped.xml"',
84+
)
85+
self.assertEqual(b"".join(r.streaming_content), xml_content)
86+
87+
def test_rfcxml_notprepped_wrapper(self):
88+
number = settings.FIRST_V3_RFC
89+
90+
# 404 for pre-v3 RFC numbers (no document needed)
91+
r = self.client.get(
92+
urlreverse(
93+
"ietf.doc.views_doc.rfcxml_notprepped_wrapper",
94+
kwargs=dict(number=number - 1),
95+
)
96+
)
97+
self.assertEqual(r.status_code, 404)
98+
99+
# 404 when no RFC document exists in the database
100+
r = self.client.get(
101+
urlreverse(
102+
"ietf.doc.views_doc.rfcxml_notprepped_wrapper",
103+
kwargs=dict(number=number),
104+
)
105+
)
106+
self.assertEqual(r.status_code, 404)
107+
108+
# 200 with rendered template when RFC document exists
109+
rfc = WgRfcFactory(rfc_number=number)
110+
r = self.client.get(
111+
urlreverse(
112+
"ietf.doc.views_doc.rfcxml_notprepped_wrapper",
113+
kwargs=dict(number=number),
114+
)
115+
)
116+
self.assertEqual(r.status_code, 200)
117+
q = PyQuery(r.content)
118+
self.assertIn(str(rfc.rfc_number), q("h1").text())
119+
download_url = urlreverse(
120+
"ietf.doc.views_doc.rfcxml_notprepped", kwargs=dict(number=number)
121+
)
122+
self.assertEqual(len(q(f'a.btn[href="{download_url}"]')), 1)

ietf/doc/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@
9999

100100
url(r'^%(name)s(?:/%(rev)s)?/$' % settings.URL_REGEXPS, views_doc.document_main),
101101
url(r'^%(name)s(?:/%(rev)s)?/bibtex/$' % settings.URL_REGEXPS, views_doc.document_bibtex),
102+
url(r'^rfc(?P<number>[0-9]+)/notprepped/$' , views_doc.rfcxml_notprepped),
103+
url(r'^rfc(?P<number>[0-9]+)/notprepped-wrapper/$', views_doc.rfcxml_notprepped_wrapper),
102104
url(r'^%(name)s(?:/%(rev)s)?/idnits2-state/$' % settings.URL_REGEXPS, views_doc.idnits2_state),
103105
url(r'^bibxml3/reference.I-D.%(name)s(?:-%(rev)s)?.xml$' % settings.URL_REGEXPS, views_doc.document_bibxml_ref),
104106
url(r'^bibxml3/%(name)s(?:-%(rev)s)?.xml$' % settings.URL_REGEXPS, views_doc.document_bibxml),

ietf/doc/views_doc.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright The IETF Trust 2009-2024, All Rights Reserved
1+
# Copyright The IETF Trust 2009-2026, All Rights Reserved
22
# -*- coding: utf-8 -*-
33
#
44
# Parts Copyright (C) 2009-2010 Nokia Corporation and/or its subsidiary(-ies).
@@ -43,9 +43,10 @@
4343

4444
from celery.result import AsyncResult
4545
from django.core.cache import caches
46+
from django.core.files.base import ContentFile
4647
from django.core.exceptions import PermissionDenied
4748
from django.db.models import Max
48-
from django.http import HttpResponse, Http404, HttpResponseBadRequest, JsonResponse
49+
from django.http import FileResponse, HttpResponse, Http404, HttpResponseBadRequest, JsonResponse
4950
from django.shortcuts import render, get_object_or_404, redirect
5051
from django.template.loader import render_to_string
5152
from django.urls import reverse as urlreverse
@@ -57,7 +58,7 @@
5758
import debug # pyflakes:ignore
5859

5960
from ietf.doc.models import ( Document, DocHistory, DocEvent, BallotDocEvent, BallotType,
60-
ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
61+
ConsensusDocEvent, NewRevisionDocEvent, StoredObject, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
6162
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor,
6263
RelatedDocument, RelatedDocHistory)
6364
from ietf.doc.tasks import investigate_fragment_task
@@ -86,6 +87,7 @@
8687
from ietf.review.models import ReviewAssignment
8788
from ietf.review.utils import can_request_review_of_doc, review_assignments_to_list_for_docs, review_requests_to_list_for_docs
8889
from ietf.review.utils import no_review_from_teams_on_doc
90+
from ietf.doc.storage_utils import retrieve_bytes
8991
from ietf.utils import markup_txt, log, markdown
9092
from ietf.utils.draft import get_status_from_draft_text
9193
from ietf.utils.meetecho import MeetechoAPIError, SlidesManager
@@ -2356,3 +2358,29 @@ def investigate(request):
23562358
"results": results,
23572359
},
23582360
)
2361+
2362+
def rfcxml_notprepped(request, number):
2363+
number = int(number)
2364+
if number < settings.FIRST_V3_RFC:
2365+
raise Http404
2366+
rfc = Document.objects.filter(type="rfc", rfc_number=number).first()
2367+
if rfc is None:
2368+
raise Http404
2369+
name = f"notprepped/rfc{number}.notprepped.xml"
2370+
if not StoredObject.objects.filter(name=name).exists():
2371+
raise Http404
2372+
try:
2373+
bytes = retrieve_bytes("rfc", name)
2374+
except FileNotFoundError:
2375+
raise Http404
2376+
return FileResponse(ContentFile(bytes, name=f"rfc{number}.notprepped.xml"), as_attachment=True)
2377+
2378+
2379+
def rfcxml_notprepped_wrapper(request, number):
2380+
number = int(number)
2381+
if number < settings.FIRST_V3_RFC:
2382+
raise Http404
2383+
rfc = Document.objects.filter(type="rfc", rfc_number=number).first()
2384+
if rfc is None:
2385+
raise Http404
2386+
return render(request, "doc/notprepped_wrapper.html", context={"rfc": rfc})

0 commit comments

Comments
 (0)