Skip to content
25 changes: 20 additions & 5 deletions ietf/api/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,29 @@
from django.core.exceptions import ImproperlyConfigured
from rest_framework import routers

class PrefixedSimpleRouter(routers.SimpleRouter):
"""SimpleRouter that adds a dot-separated prefix to its basename"""

class PrefixedBasenameMixin:
"""Mixin to add a prefix to the basename of a rest_framework BaseRouter"""
def __init__(self, name_prefix="", *args, **kwargs):
self.name_prefix = name_prefix
if len(self.name_prefix) == 0 or self.name_prefix[-1] == ".":
raise ImproperlyConfigured("Cannot use a name_prefix that is empty or ends with '.'")
super().__init__(*args, **kwargs)

def get_default_basename(self, viewset):
basename = super().get_default_basename(viewset)
return f"{self.name_prefix}.{basename}"
def register(self, prefix, viewset, basename=None):
# Get the superclass "register" method from the class this is mixed-in with.
# This avoids typing issues with calling super().register() directly in a
# mixin class.
super_register = getattr(super(), "register")
if not super_register or not callable(super_register):
raise TypeError("Must mixin with superclass that has register() method")
super_register(prefix, viewset, basename=f"{self.name_prefix}.{basename}")


class PrefixedSimpleRouter(PrefixedBasenameMixin, routers.SimpleRouter):
"""SimpleRouter that adds a dot-separated prefix to its basename"""


class PrefixedDefaultRouter(PrefixedBasenameMixin, routers.DefaultRouter):
"""SimpleRouter that adds a dot-separated prefix to its basename"""

9 changes: 8 additions & 1 deletion ietf/api/serializers_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,14 @@ class RfcFileSerializer(serializers.Serializer):
# in a ListField, so we use that to convey the file format of each item. There
# are other options we could consider (e.g., a structured CharField) but this
# works.
allowed_extensions = (".xml", ".txt", ".html", ".txt.pdf")
allowed_extensions = (
".html",
".json",
".notprepped.xml",
".pdf",
".txt",
".xml",
)

rfc = serializers.SlugRelatedField(
slug_field="rfc_number",
Expand Down
288 changes: 259 additions & 29 deletions ietf/api/tests_views_rpc.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,45 @@
# Copyright The IETF Trust 2025, All Rights Reserved
# -*- coding: utf-8 -*-
from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory

from django.conf import settings
from django.core.files.base import ContentFile
from django.db.models import Max
from django.test.utils import override_settings
from django.urls import reverse as urlreverse

from ietf.doc.factories import IndividualDraftFactory
from ietf.doc.models import RelatedDocument
from ietf.utils.test_utils import TestCase, reload_db_objects
from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory
from ietf.doc.models import RelatedDocument, Document
from ietf.group.factories import RoleFactory, GroupFactory
from ietf.person.factories import PersonFactory
from ietf.utils.test_utils import APITestCase, reload_db_objects


class RpcApiTests(TestCase):
class RpcApiTests(APITestCase):
@override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]})
def test_api_refs(self):
def test_draftviewset_references(self):
viewname = "ietf.api.purple_api.draft-references"

# non-existent draft
url = urlreverse("ietf.api.views_rpc.rpc_draft_refs", kwargs={"doc_id": 999999})
bad_id = Document.objects.aggregate(unused_id=Max("id") + 100)["unused_id"]
url = urlreverse(viewname, kwargs={"doc_id": bad_id})
# Without credentials
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.status_code, 403)
# Add credentials
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
jsondata = r.json()
refs = jsondata["references"]
self.assertEqual(refs, [])
self.assertEqual(r.status_code, 404)

# draft without any normative references
draft = IndividualDraftFactory()
draft = reload_db_objects(draft)
url = urlreverse(
"ietf.api.views_rpc.rpc_draft_refs", kwargs={"doc_id": draft.id}
)
url = urlreverse(viewname, kwargs={"doc_id": draft.id})
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.status_code, 403)
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
jsondata = r.json()
refs = jsondata["references"]
self.assertEqual(r.status_code, 200)
refs = r.json()
self.assertEqual(refs, [])

# draft without any normative references but with an informative reference
Expand All @@ -40,14 +48,12 @@ def test_api_refs(self):
RelatedDocument.objects.create(
source=draft, target=draft_foo, relationship_id="refinfo"
)
url = urlreverse(
"ietf.api.views_rpc.rpc_draft_refs", kwargs={"doc_id": draft.id}
)
url = urlreverse(viewname, kwargs={"doc_id": draft.id})
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.status_code, 403)
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
jsondata = r.json()
refs = jsondata["references"]
self.assertEqual(r.status_code, 200)
refs = r.json()
self.assertEqual(refs, [])

# draft with a normative reference
Expand All @@ -56,14 +62,238 @@ def test_api_refs(self):
RelatedDocument.objects.create(
source=draft, target=draft_bar, relationship_id="refnorm"
)
url = urlreverse(
"ietf.api.views_rpc.rpc_draft_refs", kwargs={"doc_id": draft.id}
)
url = urlreverse(viewname, kwargs={"doc_id": draft.id})
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.status_code, 403)
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
jsondata = r.json()
refs = jsondata["references"]
self.assertEqual(r.status_code, 200)
refs = r.json()
self.assertEqual(len(refs), 1)
self.assertEqual(refs[0]["id"], draft_bar.id)
self.assertEqual(refs[0]["name"], draft_bar.name)

@override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]})
def test_notify_rfc_published(self):
url = urlreverse("ietf.api.purple_api.notify_rfc_published")
area = GroupFactory(type_id="area")
draft_ad = RoleFactory(group=area, name_id="ad").person
authors = PersonFactory.create_batch(2)
draft = WgDraftFactory(group__parent=area, authors=authors)
assert isinstance(draft, Document), "WgDraftFactory should generate a Document"
unused_rfc_number = (
Document.objects.filter(rfc_number__isnull=False).aggregate(
unused_rfc_number=Max("rfc_number") + 1
)["unused_rfc_number"]
or 10000
)

post_data = {
"published": "2025-12-17T20:29:00Z",
"draft_name": draft.name,
"draft_rev": draft.rev,
"rfc_number": unused_rfc_number,
"title": draft.title,
"authors": [
{
"titlepage_name": f"titlepage {author.name}",
"is_editor": False,
"person": author.pk,
"email": author.email_address(),
"affiliation": "Some Affiliation",
"country": "CA",
}
for author in authors
],
"group": draft.group.acronym,
"stream": draft.stream_id,
"abstract": draft.abstract,
"pages": draft.pages,
"words": draft.pages * 250,
"formal_languages": [],
"std_level": "ps",
"ad": draft_ad.pk,
"note": "noted",
"obsoletes": [],
"updates": [],
"subseries": [],
}
r = self.client.post(url, data=post_data, format="json")
self.assertEqual(r.status_code, 403)

r = self.client.post(
url, data=post_data, format="json", headers={"X-Api-Key": "valid-token"}
)
self.assertEqual(r.status_code, 200)
rfc = Document.objects.filter(rfc_number=unused_rfc_number).first()
self.assertIsNotNone(rfc)
self.assertEqual(rfc.came_from_draft(), draft)
self.assertEqual(
rfc.docevent_set.filter(
type="published_rfc", time="2025-12-17T20:29:00Z"
).count(),
1,
)
self.assertEqual(rfc.title, draft.title)
self.assertEqual(rfc.documentauthor_set.count(), 0)
self.assertEqual(
list(
rfc.rfcauthor_set.values(
"titlepage_name",
"is_editor",
"person",
"email",
"affiliation",
"country",
)
),
[
{
"titlepage_name": f"titlepage {author.name}",
"is_editor": False,
"person": author.pk,
"email": author.email_address(),
"affiliation": "Some Affiliation",
"country": "CA",
}
for author in authors
],
)
self.assertEqual(rfc.group, draft.group)
self.assertEqual(rfc.stream, draft.stream)
self.assertEqual(rfc.abstract, draft.abstract)
self.assertEqual(rfc.pages, draft.pages)
self.assertEqual(rfc.words, draft.pages * 250)
self.assertEqual(rfc.formal_languages.count(), 0)
self.assertEqual(rfc.std_level_id, "ps")
self.assertEqual(rfc.ad, draft_ad)
self.assertEqual(rfc.note, "noted")
self.assertEqual(rfc.related_that_doc("obs"), [])
self.assertEqual(rfc.related_that_doc("updates"), [])
self.assertEqual(rfc.part_of(), [])
self.assertEqual(draft.get_state().slug, "rfc")
# todo test non-empty relationships
# todo test references (when updating that is part of the handling)

@override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]})
def test_upload_rfc_files(self):
def _valid_post_data():
"""Generate a valid post data dict

Each API call needs a fresh set of files, so don't reuse the return
value from this for multiple calls!
"""
return {
"rfc": rfc.rfc_number,
"contents": [
ContentFile(b"This is .xml", "myfile.xml"),
ContentFile(b"This is .txt", "myfile.txt"),
ContentFile(b"This is .html", "myfile.html"),
ContentFile(b"This is .pdf", "myfile.pdf"),
ContentFile(b"This is .json", "myfile.json"),
ContentFile(b"This is .notprepped.xml", "myfile.notprepped.xml"),
],
"replace": False,
}

url = urlreverse("ietf.api.purple_api.upload_rfc_files")
unused_rfc_number = (
Document.objects.filter(rfc_number__isnull=False).aggregate(
unused_rfc_number=Max("rfc_number") + 1
)["unused_rfc_number"]
or 10000
)

rfc = WgRfcFactory(rfc_number=unused_rfc_number)
assert isinstance(rfc, Document), "WgRfcFactory should generate a Document"
with TemporaryDirectory() as rfc_dir:
settings.RFC_PATH = rfc_dir # affects overridden settings
rfc_path = Path(rfc_dir)
(rfc_path / "prerelease").mkdir()
content = StringIO("XML content\n")
content.name = "myrfc.xml"

# no api key
r = self.client.post(url, _valid_post_data(), format="multipart")
self.assertEqual(r.status_code, 403)

# invalid RFC
r = self.client.post(
url,
_valid_post_data() | {"rfc": unused_rfc_number + 1},
format="multipart",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)

# empty files
r = self.client.post(
url,
_valid_post_data() | {
"contents": [
ContentFile(b"", "myfile.xml"),
ContentFile(b"", "myfile.txt"),
ContentFile(b"", "myfile.html"),
ContentFile(b"", "myfile.pdf"),
ContentFile(b"", "myfile.json"),
ContentFile(b"", "myfile.notprepped.xml"),
]
},
format="multipart",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)

# bad file type
r = self.client.post(
url,
_valid_post_data() | {
"contents": [
ContentFile(b"Some content", "myfile.jpg"),
]
},
format="multipart",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)

# valid post
r = self.client.post(
url,
_valid_post_data(),
format="multipart",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 200)
for suffix in [".xml", ".txt", ".html", ".pdf", ".json"]:
self.assertEqual(
(rfc_path / f"rfc{unused_rfc_number}")
.with_suffix(suffix)
.read_text(),
f"This is {suffix}",
f"{suffix} file should contain the expected content",
)
self.assertEqual(
(
rfc_path / "prerelease" / f"rfc{unused_rfc_number}.notprepped.xml"
).read_text(),
"This is .notprepped.xml",
".notprepped.xml file should contain the expected content",
)

# re-post with replace = False should now fail
r = self.client.post(
url,
_valid_post_data(),
format="multipart",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 409) # conflict

# re-post with replace = True should succeed
r = self.client.post(
url,
_valid_post_data() | {"replace": True},
format="multipart",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 200) # conflict
Loading