diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 820887a07e6..ffddad2c4c8 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -21,14 +21,6 @@ updates:
- "rjsparks"
- package-ecosystem: "npm"
directory: "/"
- schedule:
- interval: "weekly"
- groups:
- yarn:
- patterns:
- - "*"
- - package-ecosystem: "npm"
- directory: "/playwright"
schedule:
interval: "weekly"
groups:
@@ -36,7 +28,7 @@ updates:
patterns:
- "*"
- package-ecosystem: "npm"
- directory: "/dev/coverage-action"
+ directory: "/playwright"
schedule:
interval: "weekly"
groups:
diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml
index 361174ea259..ca38345beee 100644
--- a/.github/workflows/reusable-build.yml
+++ b/.github/workflows/reusable-build.yml
@@ -49,7 +49,7 @@ jobs:
fetch-tags: false
- name: Setup Node.js environment
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: 18.x
@@ -81,7 +81,7 @@ jobs:
echo "Installing node packages..."
npm ci
echo "Packaging static assets..."
- npm run build --base=https://static.ietf.org/dt/${{ inputs.buildVersion }}/
+ npm run build -- --base=https://static.ietf.org/dt/${{ inputs.buildVersion }}/
npm run legacy:build
echo "Setting version ${{ inputs.buildVersion }}..."
sed -i -r -e "s|^__version__ += '.*'$|__version__ = '${{ inputs.buildVersion }}'|" ietf/__init__.py
@@ -93,7 +93,7 @@ jobs:
run: |
echo "Setting production flags in settings.py..."
sed -i -r -e 's/^DEBUG *= *.*$/DEBUG = False/' -e "s/^SERVER_MODE *= *.*\$/SERVER_MODE = 'production'/" ietf/settings.py
-
+
- name: Make Tarball
env:
DEBIAN_FRONTEND: noninteractive
@@ -158,7 +158,7 @@ jobs:
ghcr.io/ietf-tools/datatracker:${{ inputs.buildVersion }}
${{ env.FEATURE_LATEST_TAG && format('ghcr.io/ietf-tools/datatracker:{0}-latest', env.FEATURE_LATEST_TAG) || null }}
- - name: Update CHANGELOG
+ - name: Generate CHANGELOG
id: changelog
uses: Requarks/changelog-action@v1
if: ${{ inputs.isReleaseBuild }}
@@ -170,29 +170,10 @@ jobs:
- name: Download Coverage Results
if: ${{ inputs.handleCoverageResults }}
- uses: actions/download-artifact@v8.0.1
+ uses: actions/download-artifact@v8
with:
name: coverage
- - name: Prepare Coverage Action
- if: ${{ inputs.handleCoverageResults }}
- working-directory: ./dev/coverage-action
- run: npm install
-
- - name: Process Coverage Stats + Chart
- id: covprocess
- uses: ./dev/coverage-action/
- if: ${{ inputs.handleCoverageResults }}
- with:
- token: ${{ github.token }}
- tokenCommon: ${{ secrets.GH_COMMON_TOKEN }}
- repoCommon: common
- version: ${{ inputs.buildVersion }}
- changelog: ${{ steps.changelog.outputs.changes }}
- summary: ''
- coverageResultsPath: coverage.json
- histCoveragePath: historical-coverage.json
-
- name: Create Release
uses: ncipollo/release-action@v1.21.0
if: ${{ inputs.isReleaseBuild }}
@@ -202,8 +183,8 @@ jobs:
draft: false
tag: ${{ inputs.buildVersion }}
name: ${{ inputs.buildVersion }}
- body: ${{ steps.covprocess.outputs.changelog }}
- artifacts: "/home/runner/work/release/release.tar.gz,coverage.json,historical-coverage.json"
+ body: ${{ steps.changelog.outputs.changes }}
+ artifacts: "/home/runner/work/release/release.tar.gz,coverage.json"
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update Baseline Coverage
@@ -218,7 +199,7 @@ jobs:
replacesArtifacts: true
artifacts: "coverage.json"
token: ${{ secrets.GITHUB_TOKEN }}
-
+
- name: Upload Build Artifacts
uses: actions/upload-artifact@v7
with:
diff --git a/.github/workflows/reusable-tests.yml b/.github/workflows/reusable-tests.yml
index d60dc9857ce..90d09970acb 100644
--- a/.github/workflows/reusable-tests.yml
+++ b/.github/workflows/reusable-tests.yml
@@ -104,7 +104,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v7
with:
node-version: '26'
diff --git a/README.md b/README.md
index 7e2ee131f02..48dfdae27e4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
-

+

+
+# Datatracker
[](https://github.com/ietf-tools/datatracker/releases)
[](https://github.com/ietf-tools/datatracker/blob/main/LICENSE)
diff --git a/dev/coverage-action/package-lock.json b/dev/coverage-action/package-lock.json
index 1febf8d88b5..a63148186fe 100644
--- a/dev/coverage-action/package-lock.json
+++ b/dev/coverage-action/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"@actions/core": "3.0.1",
"@actions/github": "9.1.1",
- "es-toolkit": "1.49.0"
+ "es-toolkit": "1.50.0"
}
},
"node_modules/@actions/core": {
@@ -222,14 +222,9 @@
}
},
"node_modules/es-toolkit": {
- "version": "1.49.0",
- "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
- "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
- "license": "MIT",
- "workspaces": [
- "docs",
- "benchmarks"
- ]
+ "version": "1.50.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
+ "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="
},
"node_modules/json-with-bigint": {
"version": "3.5.8",
@@ -418,9 +413,9 @@
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="
},
"es-toolkit": {
- "version": "1.49.0",
- "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
- "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="
+ "version": "1.50.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
+ "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="
},
"json-with-bigint": {
"version": "3.5.8",
diff --git a/dev/coverage-action/package.json b/dev/coverage-action/package.json
index f51a78fd879..4607922d81a 100644
--- a/dev/coverage-action/package.json
+++ b/dev/coverage-action/package.json
@@ -9,6 +9,6 @@
"dependencies": {
"@actions/core": "3.0.1",
"@actions/github": "9.1.1",
- "es-toolkit": "1.49.0"
+ "es-toolkit": "1.50.0"
}
}
diff --git a/dev/tests/prepare.sh b/dev/tests/prepare.sh
index dd8e3d6b4d5..47917e45449 100644
--- a/dev/tests/prepare.sh
+++ b/dev/tests/prepare.sh
@@ -9,10 +9,10 @@ cp ./dev/tests/settings_local.py ./ietf/settings_local.py
echo "Ensure all requirements.txt packages are installed..."
pip --disable-pip-version-check --no-cache-dir install -r requirements.txt
echo "Compiling native node packages..."
-yarn rebuild
+npm ci
echo "Building static assets..."
-yarn build
-yarn legacy:build
+npm run build
+npm run legacy:build
echo "Creating data directories..."
chmod +x ./docker/scripts/app-create-dirs.sh
./docker/scripts/app-create-dirs.sh
diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py
index f4bfe8330ee..30254221d12 100644
--- a/ietf/api/__init__.py
+++ b/ietf/api/__init__.py
@@ -1,28 +1,26 @@
# Copyright The IETF Trust 2014-2020, All Rights Reserved
-# -*- coding: utf-8 -*-
import datetime
import re
import sys
-
from urllib.parse import urlencode
+import tastypie.resources
+import tastypie.serializers
from django.apps import apps as django_apps
from django.core.exceptions import ObjectDoesNotExist
+from django.db import DataError, transaction
from django.http import HttpResponseNotAllowed
from django.utils.module_loading import autodiscover_modules
-
-
-import debug # pyflakes:ignore
-
-import tastypie.resources
-import tastypie.serializers
from tastypie.api import Api
from tastypie.bundle import Bundle
-from tastypie.exceptions import ApiFieldError
+from tastypie.exceptions import ApiFieldError, BadRequest, InvalidFilterError
from tastypie.fields import ApiField
+import debug # noqa: F401 (pyflakes:ignore)
+from ietf.utils.log import log
+
_api_list = []
OMITTED_APPS_APIS = ["ietf.status"]
@@ -30,16 +28,17 @@
# Pre-py3.11, fromisoformat() does not handle Z or +HH tz offsets
HAVE_BROKEN_FROMISOFORMAT = sys.version_info < (3, 11, 0, "", 0)
+
def populate_api_list():
_module_dict = globals()
for app_config in django_apps.get_app_configs():
- if '.' in app_config.name and app_config.name not in OMITTED_APPS_APIS:
- _root, _name = app_config.name.split('.', 1)
- if _root == 'ietf':
- if not '.' in _name:
- _api = Api(api_name=_name)
- _module_dict[_name] = _api
- _api_list.append((_name, _api))
+ if "." in app_config.name and app_config.name not in OMITTED_APPS_APIS:
+ _root, _name = app_config.name.split(".", 1)
+ if _root == "ietf" and "." not in _name:
+ _api = Api(api_name=_name)
+ _module_dict[_name] = _api
+ _api_list.append((_name, _api))
+
def autodiscover():
"""
@@ -51,6 +50,40 @@ def autodiscover():
class ModelResource(tastypie.resources.ModelResource):
+ def dispatch(self, request_type, request, **kwargs):
+ """Turn a database error caused by request data into a bad request
+
+ Filter values reach the database with very little validation, and some of
+ them only fail once the query actually runs - below tastypie, and long
+ after build_filters() had any chance to reject them. Left alone those
+ surface as unhandled exceptions.
+
+ Only DataError is treated this way: it is the DBAPI error for a problem
+ with the data in the query, so it is the client's to fix. The other
+ DatabaseError subclasses (OperationalError, ProgrammingError,
+ InternalError) indicate a broken database or a bug of ours, and are left
+ alone so they still raise and report.
+
+ The database's message is logged rather than returned - it can quote the
+ offending value, and this response body is not escaped.
+ """
+ try:
+ return super().dispatch(request_type, request, **kwargs)
+ except DataError as err:
+ # The failed statement has aborted the transaction if there is one, so
+ # nothing more can be done with the connection until it is rolled back.
+ # Requests normally run in autocommit, where there is no transaction to
+ # roll back and this is a no-op, but without it the guard would quietly
+ # stop working if ATOMIC_REQUESTS were ever turned on: the 400 would be
+ # built and then lost when the atomic block failed to commit.
+ if not transaction.get_autocommit():
+ transaction.set_rollback(True)
+ log(f"DataError handling {request.method} {request.get_full_path()}: {err}")
+ raise BadRequest(
+ "The database could not process this request. This is usually a "
+ "malformed filter value."
+ )
+
def post_detail(self, request, **kwargs):
return HttpResponseNotAllowed(["GET"])
@@ -60,11 +93,11 @@ def generate_cache_key(self, *args, **kwargs):
This is based off the current api_name/resource_name/args/kwargs.
"""
- #smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()]
+ # smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()]
smooshed = urlencode(kwargs)
# Use a list plus a ``.join()`` because it's faster than concatenation.
- return "%s:%s:%s:%s" % (self._meta.api_name, self._meta.resource_name, ':'.join(args), smooshed)
+ return f"{self._meta.api_name}:{self._meta.resource_name}:{':'.join(args)}:{smooshed}"
def _z_aware_fromisoformat(self, value: str) -> datetime.datetime:
"""datetime.datetime.fromisoformat replacement that works with python < 3.11"""
@@ -78,9 +111,27 @@ def _z_aware_fromisoformat(self, value: str) -> datetime.datetime:
def filter_value_to_python(
self, value, field_name, filters, filter_expr, filter_type
):
- py_value = super().filter_value_to_python(
- value, field_name, filters, filter_expr, filter_type
- )
+ try:
+ py_value = super().filter_value_to_python(
+ value, field_name, filters, filter_expr, filter_type
+ )
+ except TypeError:
+ # For "in" and "range" filters tastypie calls len() on the value, but
+ # string_to_python() has already mapped "true"/"false"/"nil"/"none" to a
+ # bool or None, which have no len().
+ raise InvalidFilterError(
+ f"Invalid value for the '{filter_type}' filter on '{field_name}'"
+ )
+ if filter_type == "range" and len(py_value) != 2:
+ # Django renders a range lookup as "BETWEEN %s AND %s" and indexes the
+ # value without checking its length, so anything other than exactly two
+ # values raises IndexError (or ValueError) when the query is compiled -
+ # long after this method has returned, where it can only become a 500.
+ # Reject it here, while it can still be reported as a bad request.
+ raise InvalidFilterError(
+ f"The '{filter_type}' filter on '{field_name}' requires exactly two "
+ f"comma-separated values"
+ )
if isinstance(
self.fields[field_name], tastypie.fields.DateTimeField
) and isinstance(py_value, str):
@@ -96,10 +147,13 @@ def filter_value_to_python(
return py_value
-TIMEDELTA_REGEX = re.compile(r'^(?P
\d+d)?\s?(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s?)$')
+TIMEDELTA_REGEX = re.compile(
+ r"^(?P\d+d)?\s?(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s?)$"
+)
+
class TimedeltaField(ApiField):
- dehydrated_type = 'timedelta'
+ dehydrated_type = "timedelta"
help_text = "A timedelta field, with duration expressed in seconds. Ex: 132"
def convert(self, value):
@@ -111,33 +165,50 @@ def convert(self, value):
if match:
data = match.groupdict()
- return datetime.timedelta(int(data['days']), int(data['hours']), int(data['minutes']), int(data['seconds']))
+ return datetime.timedelta(
+ int(data["days"]),
+ int(data["hours"]),
+ int(data["minutes"]),
+ int(data["seconds"]),
+ )
else:
- raise ApiFieldError("Timedelta provided to '%s' field doesn't appear to be a valid timedelta string: '%s'" % (self.instance_name, value))
+ raise ApiFieldError(
+ f"Timedelta provided to '{self.instance_name}' field doesn't appear to be a valid timedelta string: '{value}'"
+ )
return value
def hydrate(self, bundle):
- value = super(TimedeltaField, self).hydrate(bundle)
+ value = super().hydrate(bundle)
- if value and not hasattr(value, 'seconds'):
+ if value and not hasattr(value, "seconds"):
if isinstance(value, str):
try:
match = TIMEDELTA_REGEX.search(value)
if match:
data = match.groupdict()
- value = datetime.timedelta(int(data['days']), int(data['hours']), int(data['minutes']), int(data['seconds']))
+ value = datetime.timedelta(
+ int(data["days"]),
+ int(data["hours"]),
+ int(data["minutes"]),
+ int(data["seconds"]),
+ )
else:
raise ValueError()
except (ValueError, TypeError):
- raise ApiFieldError("Timedelta provided to '%s' field doesn't appear to be a valid datetime string: '%s'" % (self.instance_name, value))
+ raise ApiFieldError(
+ f"Timedelta provided to '{self.instance_name}' field doesn't appear to be a valid datetime string: '{value}'"
+ )
else:
- raise ApiFieldError("Datetime provided to '%s' field must be a string: %s" % (self.instance_name, value))
+ raise ApiFieldError(
+ f"Datetime provided to '{self.instance_name}' field must be a string: {value}"
+ )
return value
+
class ToOneField(tastypie.fields.ToOneField):
"Subclass of tastypie.fields.ToOneField which adds caching in the dehydrate method."
@@ -145,7 +216,7 @@ def dehydrate(self, bundle, for_list=True):
foreign_obj = None
previous_obj = None
attrib = None
-
+
if callable(self.attribute):
previous_obj = bundle.obj
foreign_obj = self.attribute(bundle)
@@ -163,25 +234,34 @@ def dehydrate(self, bundle, for_list=True):
if not foreign_obj:
if not self.null:
if callable(self.attribute):
- raise ApiFieldError("The related resource for resource %s could not be found." % (previous_obj))
+ raise ApiFieldError(
+ f"The related resource for resource {previous_obj} could not be found."
+ )
else:
- raise ApiFieldError("The model '%r' has an empty attribute '%s' and doesn't allow a null value." % (previous_obj, attrib))
+ raise ApiFieldError(
+ f"The model '{previous_obj!r}' has an empty attribute '{attrib}' and doesn't allow a null value."
+ )
return None
fk_resource = self.get_related_resource(foreign_obj)
# Up to this point we've copied the code from tastypie 0.13.1. Now
# we add caching.
- cache_key = fk_resource.generate_cache_key('related', pk=foreign_obj.pk, for_list=for_list, )
+ cache_key = fk_resource.generate_cache_key(
+ "related",
+ pk=foreign_obj.pk,
+ for_list=for_list,
+ )
dehydrated = fk_resource._meta.cache.get(cache_key)
if dehydrated is None:
fk_bundle = Bundle(obj=foreign_obj, request=bundle.request)
- dehydrated = self.dehydrate_related(fk_bundle, fk_resource, for_list=for_list)
+ dehydrated = self.dehydrate_related(
+ fk_bundle, fk_resource, for_list=for_list
+ )
fk_resource._meta.cache.set(cache_key, dehydrated)
return dehydrated
-
# XML 1.0 forbids all control characters except tab (#x9), LF (#xA), and CR (#xD).
# Replace each with its Unicode control picture (U+2400 + codepoint) so the
# substitution is lossless and the result is valid XML.
@@ -192,12 +272,19 @@ class Serializer(tastypie.serializers.Serializer):
OPTION_ESCAPE_XML_INVALID = "datatracker-escape-xml-invalid"
def format_datetime(self, data):
- return data.astimezone(datetime.UTC).replace(tzinfo=None).isoformat(timespec="seconds") + "Z"
+ return (
+ data.astimezone(datetime.UTC)
+ .replace(tzinfo=None)
+ .isoformat(timespec="seconds")
+ + "Z"
+ )
def to_simple(self, data, options):
options = options or {}
simple_data = super().to_simple(data, options)
- if options.get(self.OPTION_ESCAPE_XML_INVALID, False) and isinstance(simple_data, str):
+ if options.get(self.OPTION_ESCAPE_XML_INVALID, False) and isinstance(
+ simple_data, str
+ ):
# Replace control chars invalid in XML 1.0 with their Unicode
# control pictures (U+2400-U+241F) so lxml won't reject the string.
simple_data = _XML_INVALID_CTRL_RE.sub(
diff --git a/ietf/api/tests.py b/ietf/api/tests.py
index 698bf4f04ce..f7fa1aff02c 100644
--- a/ietf/api/tests.py
+++ b/ietf/api/tests.py
@@ -12,7 +12,7 @@
from importlib import import_module
from pathlib import Path
from random import randrange
-from urllib.parse import urljoin
+from urllib.parse import quote, urlencode, urljoin
from django.apps import apps
from django.conf import settings
@@ -30,7 +30,7 @@
from ietf.doc.storage_utils import retrieve_str
from ietf.doc.utils import get_unicode_document_content
from ietf.doc.models import RelatedDocument, State
-from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory
+from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory, RfcAuthorFactory, DocEventFactory
from ietf.group.factories import RoleFactory
from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.meeting.models import Session, Registration
@@ -1071,6 +1071,145 @@ def test_role_holder_addresses(self):
sorted(e.address for e in emails),
)
+ @override_settings(
+ APP_API_TOKENS={"ietf.api.views.rfc_authors": ["valid-token"]}
+ )
+ def test_rfc_authors(self):
+ url = urlreverse("ietf.api.views.rfc_authors")
+ # auth and method checks
+ self.assertEqual(
+ self.client.get(url, headers={}).status_code, 403, "No api token, no access"
+ )
+ self.assertEqual(
+ self.client.get(url, headers={"X-Api-Key": "not-valid-token"}).status_code,
+ 403,
+ "Bad api token, no access",
+ )
+ self.assertEqual(
+ self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code,
+ 405,
+ "Bad method, no access",
+ )
+ # Timestamps of interest
+ now = timezone.now()
+ one_day_ago = now - datetime.timedelta(days=1)
+ two_days_ago = now - datetime.timedelta(days=2)
+ three_days_ago = now - datetime.timedelta(days=3)
+ long_long_ago = now - datetime.timedelta(days=400)
+
+ # A recently published RFC with a known author...
+ author = PersonFactory(name="Jane Q. Author")
+ recent_rfc = WgRfcFactory(title="A Recently Published RFC")
+ DocEventFactory(doc=recent_rfc, type="published_rfc", time=two_days_ago)
+ RfcAuthorFactory(document=recent_rfc, person=author)
+
+ # ...and an RFC published well outside the default window, which must be excluded.
+ old_rfc = WgRfcFactory(title="An Old RFC")
+ DocEventFactory(doc=old_rfc, type="published_rfc", time=long_long_ago)
+ RfcAuthorFactory(document=old_rfc)
+
+ r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.headers["Content-Type"], "application/json")
+ rows = json.loads(r.content)
+
+ # Only the recent RFC's author appears, as a single aggregated object.
+ self.assertEqual(len(rows), 1)
+ row = rows[0]
+ self.assertEqual(row["name"], "Jane Q. Author")
+ self.assertEqual(row["email"], author.email().address)
+ self.assertEqual(row["rfc_number"], str(recent_rfc.rfc_number))
+ self.assertEqual(row["rfc_name"], recent_rfc.name)
+ self.assertEqual(row["rfc_title"], recent_rfc.title)
+ self.assertEqual(
+ row["rfc_number_and_title"],
+ f"RFC {recent_rfc.rfc_number}: {recent_rfc.title}",
+ )
+ self.assertEqual(row["published_date"], str(recent_rfc.pub_date()))
+
+ # A narrow window excludes the recent RFC, too. First, using from-only
+ r = self.client.get(
+ url + "?" + urlencode({"from": one_day_ago.isoformat()}),
+ headers={"X-Api-Key": "valid-token"},
+ )
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(json.loads(r.content), [])
+ # Second, using both from and to
+ r = self.client.get(
+ url
+ + "?"
+ + urlencode({"from": one_day_ago.isoformat(), "to": now.isoformat()}),
+ headers={"X-Api-Key": "valid-token"},
+ )
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(json.loads(r.content), [])
+ # Third, from and to, but on the other side of the recent event. This also
+ # confirms that the "to" side comparison is < and not <=.
+ r = self.client.get(
+ url
+ + "?"
+ + urlencode(
+ {"from": three_days_ago.isoformat(), "to": two_days_ago.isoformat()}
+ ),
+ headers={"X-Api-Key": "valid-token"},
+ )
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(json.loads(r.content), [])
+
+ # Make sure the "from" side is >= and not >.
+ r = self.client.get(
+ url
+ + "?"
+ + urlencode(
+ {"from": two_days_ago.isoformat(), "to": one_day_ago.isoformat()}
+ ),
+ headers={"X-Api-Key": "valid-token"},
+ )
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(len(json.loads(r.content)), 1)
+
+ # The testing parameter fakes the email domain while keeping the mailbox.
+ r = self.client.get(url + "?testing", headers={"X-Api-Key": "valid-token"})
+ self.assertEqual(r.status_code, 200)
+ rows = json.loads(r.content)
+ self.assertEqual(len(rows), 1)
+ mailbox = author.email().address.split("@", 1)[0]
+ self.assertEqual(rows[0]["email"], f"{mailbox}@fake.example.com")
+ # Non-email fields are unaffected.
+ self.assertEqual(rows[0]["name"], "Jane Q. Author")
+
+ # If in test mode and testaddr parameters are present, records for those
+ # addresses should be returned with a fake RFC.
+ r = self.client.get(
+ url + "?testing&testaddr=fake@a.example.com&testaddr=phony@b.example.com",
+ headers={"X-Api-Key": "valid-token"},
+ )
+ self.assertEqual(r.status_code, 200)
+ rows = json.loads(r.content)
+ self.assertEqual(len(rows), 3)
+ fake_author_addr = author.email().address.split("@", 1)[0] + "@fake.example.com"
+ self.assertCountEqual(
+ [fake_author_addr, "fake@a.example.com", "phony@b.example.com"],
+ [r["email"] for r in rows],
+ )
+
+ # Can only use testaddr when testing is also present
+ r = self.client.get(
+ url + "?testaddr=fake.a.example.com", headers={"X-Api-Key": "valid-token"}
+ )
+ self.assertEqual(r.status_code, 400)
+
+ # Invalid to/from parameters are rejected.
+ r = self.client.get(url + "?from=garbage", headers={"X-Api-Key": "valid-token"})
+ self.assertEqual(r.status_code, 400, "bad from parameter")
+ r = self.client.get(url + "?to=garbage", headers={"X-Api-Key": "valid-token"})
+ self.assertEqual(r.status_code, 400, "bad to parameter")
+ r = self.client.get(
+ url + f"?from={two_days_ago.isoformat()}&to={three_days_ago.isoformat()}",
+ headers={"X-Api-Key": "valid-token"},
+ )
+ self.assertEqual(r.status_code, 400, "out-of-order from/to parameters")
+
@override_settings(
APP_API_TOKENS={"ietf.api.views.ingest_email": "valid-token", "ietf.api.views.ingest_email_test": "test-token"}
)
@@ -1524,6 +1663,93 @@ def test_api_top_level(self):
self.assertIn(name, resource_list,
"Expected a REST API resource for %s, but didn't find one" % name)
+ def test_api_top_level_bad_accept_header(self):
+ """A malformed Accept header is rejected without reflecting its content
+
+ The response body is served as unescaped text/html, and this is an
+ unauthenticated GET endpoint, so nothing derived from the request may
+ appear in it.
+ """
+ payload = "
"
+ r = self.client.get("/api/v1/", headers={"accept": payload})
+ self.assertEqual(r.status_code, 400)
+ self.assertNotIn(payload, r.content.decode("utf-8"))
+
+ def _assert_filter_is_bad_request(self, querystring, leaked):
+ """Assert a filter the database rejects gives a 400 that leaks nothing
+
+ Only one such request per test method: this class runs each test inside a
+ transaction, and the failed statement aborts it, so a second query in the
+ same test raises InternalError. Production requests run in autocommit and
+ are unaffected - each is a single request that returns immediately.
+ """
+ r = self.client.get("/api/v1/doc/document/?format=json&limit=1&" + querystring)
+ self.assertEqual(r.status_code, 400, "Expected 400 for %s" % querystring)
+ body = r.content.decode("utf-8")
+ # the database quotes its own diagnostics - none of that should come back
+ self.assertNotIn("invalid regular expression", body)
+ self.assertNotIn(leaked, body)
+
+ def test_database_error_unbalanced_bracket(self):
+ """A filter value the database rejects is a bad request, not a 500
+
+ An invalid regex is only rejected once the query runs, below anything
+ tastypie can validate. See ietf.api.ModelResource.dispatch.
+ """
+ self._assert_filter_is_bad_request("name__regex=%5B", "brackets")
+
+ def test_database_error_unbalanced_paren(self):
+ self._assert_filter_is_bad_request("name__regex=%28", "parentheses")
+
+ def test_database_error_bad_quantifier(self):
+ self._assert_filter_is_bad_request("name__iregex=" + quote("a{2,1}"), "quantifier")
+
+ def test_valid_regex_filter_still_works(self):
+ for q in ("name__regex=^draft-", "name__iregex=^DRAFT-", "name__regex=(quic|tls)"):
+ r = self.client.get("/api/v1/doc/document/?format=json&limit=1&" + quote(q, safe="=&"))
+ self.assertEqual(r.status_code, 200, "Expected 200 for %s" % q)
+
+ def test_malformed_range_filter(self):
+ """A range filter without exactly two values is a bad request, not a 500
+
+ Django renders a range lookup as "BETWEEN %s AND %s" and indexes the value
+ without checking its length, so a wrong number of values raises IndexError
+ when the query is compiled - too late for tastypie to report it as anything
+ but a 500. See ietf.api.ModelResource.filter_value_to_python.
+
+ Note the test client sets SERVER_NAME to "testserver", which makes tastypie
+ re-raise unhandled exceptions rather than converting them to a 500, so a
+ regression here surfaces as an error rather than a wrong status code.
+ """
+ # The double-encoded comma from the request that broke in production: %252C
+ # decodes to the literal text "%2C", so the value never splits into two.
+ r = self.client.get(
+ "/api/v1/doc/document/?format=json&limit=1&rev__range=02%252C99&type__slug=draft"
+ )
+ self.assertEqual(r.status_code, 400)
+
+ # note "%252C" not "%2C" - the latter is just a comma once the URL is decoded
+ for bad in ("", "02", "02%252C99", "02,99,77", "true", "nil"):
+ r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__range=" + bad)
+ self.assertEqual(r.status_code, 400, "Expected 400 for rev__range=%s" % bad)
+
+ # a well-formed range filter still works
+ r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__range=00,99")
+ self.assertEqual(r.status_code, 200)
+ # ... on a datetime field too, and via repeated parameters
+ r = self.client.get(
+ "/api/v1/doc/document/?format=json&limit=1"
+ "&time__range=2020-01-01T00:00:00Z&time__range=2030-01-01T00:00:00Z"
+ )
+ self.assertEqual(r.status_code, 200)
+
+ # "in" filters accept any number of values, including one
+ r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__in=00")
+ self.assertEqual(r.status_code, 200)
+ # but not a value that string_to_python() turns into a bool
+ r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__in=true")
+ self.assertEqual(r.status_code, 400)
+
def test_all_model_resources_exist(self):
client = Client(Accept='application/json')
r = client.get("/api/v1")
diff --git a/ietf/api/urls.py b/ietf/api/urls.py
index 7a082567b8a..dc1ddb6d0ca 100644
--- a/ietf/api/urls.py
+++ b/ietf/api/urls.py
@@ -44,6 +44,8 @@
# --- Custom API endpoints, sorted alphabetically ---
# Email alias information for drafts
url(r'^doc/draft-aliases/$', api_views.draft_aliases),
+ # Authors of recently published RFCs, as CSV
+ url(r'^doc/rfc-authors/$', api_views.rfc_authors),
# email ingestor
url(r'email/$', api_views.ingest_email),
# email ingestor
diff --git a/ietf/api/views.py b/ietf/api/views.py
index 420bc396934..9c4743c152a 100644
--- a/ietf/api/views.py
+++ b/ietf/api/views.py
@@ -1,5 +1,4 @@
-# Copyright The IETF Trust 2017-2020, All Rights Reserved
-# -*- coding: utf-8 -*-
+# Copyright The IETF Trust 2017-2026, All Rights Reserved
import base64
import binascii
@@ -19,6 +18,7 @@
from django.http import HttpResponse, Http404, JsonResponse, HttpResponseBadRequest
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
+from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.gzip import gzip_page
@@ -38,6 +38,7 @@
from ietf.api import _api_list
from ietf.api.ietf_utils import is_valid_token, requires_api_token
from ietf.api.serializer import JsonExportMixin
+from ietf.doc.models import Document
from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents
from ietf.group.utils import GroupAliasGenerator, role_holder_emails
from ietf.ietfauth.utils import role_required
@@ -67,7 +68,10 @@ def top_level(request):
try:
desired_format = determine_format(request, serializer)
except BadRequest as err:
- return HttpResponseBadRequest(str(err))
+ # tastypie's message is a fixed string today, but don't reflect a dependency's
+ # exception text into an unescaped text/html body on an unauthenticated endpoint.
+ log.log("Bad request determining format for api top_level: %s" % err)
+ return HttpResponseBadRequest("Invalid Accept header")
options = {}
@@ -560,6 +564,152 @@ def role_holder_addresses(request):
return HttpResponse(status=405)
+@requires_api_token
+@csrf_exempt
+def rfc_authors(request):
+ """Return authors of published RFCs as a JSON list of objects.
+
+ Finds authors by date, though this could be extended to other selection
+ criteria in the future. Specify the range as ?from=&to=.
+ Each is an ISO-8601 timestamp, treated as UTC if it does not include
+ time zone information. Defaults to `to`=now, `from`=14 days before `to`
+
+ Each author appears once, with their RFC numbers, names, titles, and
+ publication dates accumulated across every RFC they published in the window.
+
+ When the ?testing query parameter is supplied, each author's real email
+ address is replaced with a fake one that keeps the original mailbox but uses
+ the "fake.example.com" domain, so the output can be shared without exposing
+ real addresses.
+
+ When the ?testing query parameter is supplied, one or more testaddr=ADDRESS query
+ parameters can also be specified. The value of each parameter is an email
+ address. When these parameters are present, the response data will include an
+ entry for each address as though it belonged to the author of a recently published
+ RFC.
+ """
+ if request.method != "GET":
+ return HttpResponse(status=405)
+
+ # Test mode parameters
+ testing = "testing" in request.GET
+ test_addresses = request.GET.getlist("testaddr", [])
+ if len(test_addresses) > 0:
+ if not testing:
+ return HttpResponseBadRequest(
+ "Must include the testing parameter when using testaddr"
+ )
+
+ # filter parameters
+ to_param = request.GET.get("to")
+ if to_param is None:
+ # Did not receive a to parameter. Default to now().
+ time_range_end = datetime.datetime.now(datetime.UTC)
+ else:
+ # Did receive a to parameter. Parse it.
+ try:
+ time_range_end = datetime.datetime.fromisoformat(to_param)
+ except ValueError:
+ return HttpResponseBadRequest("Invalid to parameter")
+ if time_range_end.tzinfo is None:
+ time_range_end = time_range_end.replace(tzinfo=datetime.UTC)
+
+ from_param = request.GET.get("from")
+ if from_param is None:
+ # Did not receive a from parameter. Default to 14 days before end time.
+ time_range_start = time_range_end - datetime.timedelta(days=14)
+ else:
+ # Did receive a from parameter. Parse it.
+ try:
+ time_range_start = datetime.datetime.fromisoformat(from_param)
+ except ValueError:
+ return HttpResponseBadRequest("Invalid from parameter")
+ if time_range_start.tzinfo is None:
+ time_range_start = time_range_start.replace(tzinfo=datetime.UTC)
+ if time_range_start > time_range_end:
+ return HttpResponseBadRequest("Invalid time range, from is later than to")
+
+ rfcs = Document.objects.filter(
+ type_id="rfc",
+ docevent__type="published_rfc",
+ docevent__time__gte=time_range_start,
+ docevent__time__lt=time_range_end,
+ ).distinct()
+
+ # Collect per-author data keyed by email so each author gets one row.
+ # Values accumulate RFC numbers, names, titles, and dates across all RFCs.
+ author_data = {}
+
+ for rfc in rfcs:
+ # RfcAuthor is the authoritative source for RFC authors. Documents of
+ # type "rfc" always have an rfcauthor_set, so no fallback is needed.
+ authors = [
+ {
+ "name": a.person.name if a.person else a.titlepage_name,
+ "email": a.person.email().address
+ if (a.person and a.person.email())
+ else None,
+ }
+ for a in rfc.rfcauthor_set.select_related("person").order_by("order")
+ ]
+
+ for author in authors:
+ if not author["email"]:
+ continue
+
+ if testing:
+ mailbox = author["email"].split("@", 1)[0]
+ email = f"{mailbox}@fake.example.com"
+ else:
+ email = author["email"]
+
+ if email not in author_data:
+ author_data[email] = {
+ "name": author["name"],
+ "email": email,
+ "rfc_numbers": [],
+ "rfc_names": [],
+ "rfc_titles": [],
+ "published_dates": [],
+ }
+ author_data[email]["rfc_numbers"].append(str(rfc.rfc_number))
+ author_data[email]["rfc_names"].append(rfc.name)
+ author_data[email]["rfc_titles"].append(rfc.title)
+ author_data[email]["published_dates"].append(str(rfc.pub_date()))
+
+ if testing:
+ for n, email in enumerate(test_addresses):
+ if email in author_data:
+ continue # author will already be included
+ author_data[email] = {
+ "name": f"Test Author {n + 1}",
+ "email": email,
+ "rfc_numbers": ["99999"],
+ "rfc_names": ["rfc99999"],
+ "rfc_titles": ["A Fake RFC for Testing"],
+ "published_dates": [str(timezone.now().date())],
+ }
+
+ rows = []
+ for entry in author_data.values():
+ rows.append(
+ {
+ "name": entry["name"],
+ "email": entry["email"],
+ "rfc_number": ", ".join(entry["rfc_numbers"]),
+ "rfc_name": ", ".join(entry["rfc_names"]),
+ "rfc_title": ", ".join(entry["rfc_titles"]),
+ "rfc_number_and_title": ", ".join(
+ f"RFC {num}: {title}"
+ for num, title in zip(entry["rfc_numbers"], entry["rfc_titles"])
+ ),
+ "published_date": ", ".join(entry["published_dates"]),
+ }
+ )
+
+ return JsonResponse(rows, safe=False)
+
+
_response_email_json_validator = jsonschema.Draft202012Validator(
schema={
"type": "object",
diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py
index 86731000730..6f15003f92f 100644
--- a/ietf/doc/tests.py
+++ b/ietf/doc/tests.py
@@ -38,7 +38,7 @@
from ietf.doc.models import (Document, DocRelationshipName, RelatedDocument, State,
DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent, BallotType,
- EditedAuthorsDocEvent, StateType, RfcAuthor)
+ EditedAuthorsDocEvent, StateType, RfcAuthor, RpcAssignmentDocEvent)
from ietf.doc.factories import (DocumentFactory, DocEventFactory, CharterFactory,
ConflictReviewFactory, WgDraftFactory,
IndividualDraftFactory, WgRfcFactory,
@@ -1688,6 +1688,28 @@ def _change_state(doc, state):
self.assertEqual(r.status_code, 200)
self.assertNotContains(r, 'Auth48 status')
+ def test_rfceditor_queue_status_shown(self):
+ """A queued draft shows its publication-queue Status in place of the state name."""
+ draft = IndividualDraftFactory()
+ event = StateDocEventFactory(doc=draft, state=('draft-rfceditor', 'in_progress'))
+ draft.set_state(event.state)
+ draft.save_with_history([event])
+ RpcAssignmentDocEvent.objects.create(
+ doc=draft,
+ rev=draft.rev,
+ by=Person.objects.get(name="(System)"),
+ type="changed_rpc_assignments",
+ assignments="In Progress (First Edit)",
+ desc="RPC status changed to In Progress (First Edit)",
+ )
+
+ r = self.client.get(urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=draft.name)))
+ self.assertEqual(r.status_code, 200)
+ # The composite queue Status (which only comes from the RpcAssignmentDocEvent,
+ # not from the state name) is shown, confirming it replaces the raw state name.
+ self.assertContains(r, "In Progress (First Edit)")
+ self.assertContains(r, "Publication queue entry")
+
class DocTestCase(TestCase):
def test_status_change(self):
@@ -2103,6 +2125,23 @@ def test_state_help(self):
self.assertEqual(r.status_code, 200)
self.assertContains(r, State.objects.get(type="draft-iesg", slug="lc").name)
+ def test_rfceditor_state_help_has_queue_status_and_legacy_sections(self):
+ url = urlreverse('ietf.doc.views_help.state_help', kwargs=dict(type="draft-rfceditor"))
+ r = self.client.get(url)
+ self.assertEqual(r.status_code, 200)
+ # New "Queue status" section describing the queue Status values.
+ self.assertContains(r, "Queue status")
+ self.assertContains(r, "In Progress (First Edit)")
+ # Legacy states are moved to their own section with the history note.
+ self.assertContains(r, "Legacy states")
+ self.assertContains(r, "appear in the change history")
+ self.assertContains(r, State.objects.get(type="draft-rfceditor", slug="auth48").name)
+ # The queue-backing states are not listed among the legacy states.
+ q = PyQuery(r.content)
+ legacy_ids = [row.get("id") for row in q("tbody tr")]
+ self.assertNotIn("in_progress", legacy_ids)
+ self.assertNotIn("blocked", legacy_ids)
+
def test_document_nonietf_pubreq_button(self):
doc = IndividualDraftFactory()
diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py
index af056f6a96b..1472c808eb0 100644
--- a/ietf/doc/views_doc.py
+++ b/ietf/doc/views_doc.py
@@ -60,7 +60,7 @@
from ietf.doc.models import ( Document, DocHistory, DocEvent, BallotDocEvent, BallotType,
ConsensusDocEvent, NewRevisionDocEvent, StoredObject, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor,
- RelatedDocument, RelatedDocHistory)
+ RelatedDocument, RelatedDocHistory, RpcAssignmentDocEvent)
from ietf.doc.tasks import investigate_fragment_task
from ietf.doc.utils import (augment_events_with_revision,
can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id,
@@ -197,6 +197,22 @@ def interesting_doc_relations(doc):
return interesting_relations_that, interesting_relations_that_doc
+
+def rfc_editor_queue_status(doc):
+ """Human-readable RPC publication queue "Status" for the document, or None.
+
+ While a document is in the RFC Editor queue (draft-rfceditor state
+ "in_progress" or "blocked"), this is the status text pushed by the RFC
+ Production Center, matching what the queue website shows. It is displayed in
+ place of the raw draft-rfceditor state name. Returns None for documents whose
+ draft-rfceditor state predates the queue integration (they fall back to the
+ state name).
+ """
+ if doc.get_state_slug("draft-rfceditor") not in ("in_progress", "blocked"):
+ return None
+ event = doc.latest_event(RpcAssignmentDocEvent, type="changed_rpc_assignments")
+ return event.assignments if event else None
+
def document_main(request, name, rev=None, document_html=False):
doc = get_object_or_404(Document.objects.select_related(), name=name)
@@ -364,6 +380,7 @@ def document_main(request, name, rev=None, document_html=False):
has_errata=doc.pk and doc.tags.filter(slug="errata"), # doc.pk == None if using a fake_history_obj
file_urls=file_urls,
rfc_editor_state=doc.get_state("draft-rfceditor"),
+ rfc_editor_queue_status=rfc_editor_queue_status(doc),
iana_review_state=doc.get_state("draft-iana-review"),
iana_action_state=doc.get_state("draft-iana-action"),
iana_experts_state=doc.get_state("draft-iana-experts"),
@@ -707,6 +724,7 @@ def document_main(request, name, rev=None, document_html=False):
iesg_state=iesg_state,
iesg_state_summary=iesg_state_summary,
rfc_editor_state=doc.get_state("draft-rfceditor"),
+ rfc_editor_queue_status=rfc_editor_queue_status(doc),
rfc_editor_auth48_url=auth48_url,
iana_review_state=doc.get_state("draft-iana-review"),
iana_action_state=doc.get_state("draft-iana-action"),
diff --git a/ietf/doc/views_help.py b/ietf/doc/views_help.py
index 34d29aaccbc..1b6c86394d6 100644
--- a/ietf/doc/views_help.py
+++ b/ietf/doc/views_help.py
@@ -9,6 +9,43 @@
from ietf.name.models import DocRelationshipName, DocTagName
from ietf.doc.utils import get_tags_for_stream_id
+# Documentation of the values shown in the RFC Editor queue "Status" field. This
+# status is not a stored state; it is derived by the RFC Production Center's
+# publication queue from the active editor assignments, pending activities,
+# blocking reasons and IANA status of a document, and is rendered to match the
+# publication queue site (https://queue.rfc-editor.org/). Keep in sync with
+# ietf.sync.tasks.format_rpc_queue_status.
+RFC_EDITOR_QUEUE_STATUS_VALUES = [
+ ("In Progress (First Edit)",
+ "The document is being copyedited by the first editor."),
+ ("In Progress (Second Edit)",
+ "The document is getting a second review, focusing on complex issues and IANA "
+ "actions."),
+ ("In Final Review",
+ "Awaiting final approval(s) from authors and/or action holders."),
+ (" (e.g. “formatting”)",
+ "Another RPC activity is currently underway for the document; the activity is "
+ "shown by name (for example “formatting”). Reference checking and "
+ "publication are not shown as their own status."),
+ ("Awaiting ",
+ "The document is in the queue waiting for the named activity to begin. Values "
+ "include “Awaiting Formatting”, “Awaiting Reference Checker”, "
+ "“Awaiting First editor”, “Awaiting Second editor”, "
+ "“Awaiting Final review editor”, and “Awaiting Publisher”."),
+ ("Awaiting Editor Assignment",
+ "The document is in the queue but nothing has been assigned to it yet."),
+ ("IANA hold",
+ "First editing is underway but is held pending completion of IANA actions. (This "
+ "is distinct from the “IANA Hold” blocking reason below.)"),
+ ("blocked: ",
+ "Progress is blocked; one or more blocking reasons are listed after the colon. "
+ "The possible reasons are: Waiting for Action Holder, Stream Hold, External "
+ "Reference Hold, Author Input Required, IANA Hold, Reference Not Received, "
+ "Reference Not Received (2nd Generation), Reference Not Received (3rd Generation), "
+ "Reference: Second Edit Incomplete, Reference: Publish Incomplete, Final Approval "
+ "Pending, Tools Issue, and Manual Hold."),
+]
+
def state_index(request):
types = StateType.objects.all()
names = [ type.slug for type in types ]
@@ -67,6 +104,22 @@ def state_help(request, type=None):
states = State.objects.filter(used=True, type=state_type).order_by("order")
+ # The RFC Editor queue status is now driven by the RFC Production Center's
+ # publication queue rather than by the legacy draft-rfceditor states. The
+ # "in_progress"/"blocked" states back the queue-status display; the remaining
+ # states are legacy and only appear in the history of older documents.
+ queue_status_values = None
+ legacy_states_note = None
+ if state_type.slug == "draft-rfceditor":
+ states = states.exclude(slug__in=("in_progress", "blocked"))
+ queue_status_values = RFC_EDITOR_QUEUE_STATUS_VALUES
+ legacy_states_note = (
+ "These states predate the current RFC Editor publication queue and are "
+ "no longer assigned to documents. They are documented here because they "
+ "still appear in the change history of documents that were processed "
+ "before the queue integration."
+ )
+
has_next_states = False
for state in states:
if state.next_states.all():
@@ -88,6 +141,8 @@ def state_help(request, type=None):
"states": states,
"has_next_states": has_next_states,
"tags": tags,
+ "queue_status_values": queue_status_values,
+ "legacy_states_note": legacy_states_note,
} )
def relationship_help(request,subset=None):
diff --git a/ietf/group/milestones.py b/ietf/group/milestones.py
index 52f2eaebeee..c6380ffad76 100644
--- a/ietf/group/milestones.py
+++ b/ietf/group/milestones.py
@@ -21,6 +21,7 @@
from ietf.name.models import GroupMilestoneStateName
from ietf.group.mails import email_milestones_changed
from ietf.utils.fields import DatepickerDateField
+from ietf.utils.log import log
from ietf.utils.response import permission_denied
class MilestoneForm(forms.Form):
@@ -415,7 +416,11 @@ def reset_charter_milestones(request, acronym, group_type=None):
try:
milestone_ids = [int(v) for v in request.POST.getlist("milestone")]
except ValueError as e:
- return HttpResponseBadRequest("error in list of ids - %s" % e)
+ # Log the detail rather than reflecting it - the exception message from int()
+ # embeds the offending value verbatim, and HttpResponseBadRequest serves its
+ # content as unescaped text/html.
+ log("Invalid milestone id in reset_charter_milestones POST: %s" % e)
+ return HttpResponseBadRequest("error in list of ids")
# delete existing
for m in charter_milestones:
diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py
index 97ec7ebdb15..4e0096b1859 100644
--- a/ietf/group/tests_info.py
+++ b/ietf/group/tests_info.py
@@ -1760,6 +1760,41 @@ def test_reset_charter_milestones(self):
self.assertEqual(group.charter.docevent_set.count(), events_before + 2) # 1 delete, 1 add
+ def test_reset_charter_milestones_bad_ids(self):
+ """A non-integer milestone id is rejected without echoing the submitted value
+
+ int() puts the offending value in its exception message and
+ HttpResponseBadRequest serves its content as unescaped text/html, so
+ reflecting the message would be an XSS vector.
+ """
+ m1, m2, group = self.create_test_milestones()
+
+ url = urlreverse('ietf.group.milestones.reset_charter_milestones', kwargs=dict(group_type=group.type_id, acronym=group.acronym))
+ login_testing_unauthorized(self, "secretary", url)
+
+ milestones_before = GroupMilestone.objects.count()
+ events_before = group.charter.docevent_set.count()
+
+ payload = '
'
+ for bad_id in (payload, 'not-a-number', '1.5'):
+ r = self.client.post(url, dict(milestone=[str(m1.pk), bad_id]))
+ self.assertEqual(r.status_code, 400)
+ content = r.content.decode('utf-8')
+ self.assertIn('error in list of ids', content)
+ self.assertNotIn(bad_id, content)
+ self.assertNotIn('invalid literal', content)
+
+ # an empty id is also rejected, without the exception detail
+ r = self.client.post(url, dict(milestone=[str(m1.pk), '']))
+ self.assertEqual(r.status_code, 400)
+ self.assertNotIn('invalid literal', r.content.decode('utf-8'))
+
+ # nothing was changed
+ self.assertEqual(GroupMilestone.objects.count(), milestones_before)
+ self.assertEqual(group.charter.docevent_set.count(), events_before)
+ self.assertEqual(GroupMilestone.objects.get(pk=m1.pk).state_id, m1.state_id)
+ self.assertEqual(GroupMilestone.objects.get(pk=m2.pk).state_id, m2.state_id)
+
def test_edit_sort(self):
group = GroupFactory(uses_milestone_dates=False)
DatelessGroupMilestoneFactory(group=group,order=1)
diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py
index eea08be8c77..beaaf8da8a7 100644
--- a/ietf/meeting/tests_views.py
+++ b/ietf/meeting/tests_views.py
@@ -18,7 +18,7 @@
from icalendar import Calendar
from io import StringIO, BytesIO
from bs4 import BeautifulSoup
-from urllib.parse import urlparse, urlsplit
+from urllib.parse import quote, urlparse, urlsplit
from PIL import Image
from pathlib import Path
from tempfile import NamedTemporaryFile
@@ -1119,6 +1119,40 @@ def _r(show=(), hide=(), showtypes=(), hidetypes=()):
'Parsed "%s" incorrectly' % qstr,
)
+ # Unrecognized parameters are ignored, not rejected. The ical views rely on this -
+ # they cannot report a parse error, so they cannot reflect one back to the client.
+ for qstr in (
+ 'unknown=x',
+ 'show=a&unknown=x',
+ '
=1',
+ 'show=
',
+ ):
+ self.assertIsNotNone(
+ parse_agenda_filter_params(QueryDict(qstr)),
+ 'Parsing "%s" should not fail' % qstr,
+ )
+
+ def test_ical_filter_params_are_not_reflected(self):
+ """A query string must never be echoed into an ical view's response
+
+ HttpResponseBadRequest serves text/html without escaping, so reflecting a
+ parameter name or value would be a reflected XSS on an unauthenticated GET.
+ """
+ meeting = make_meeting_test_data()
+ payload = '
'
+ for url in (
+ urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num': meeting.number}),
+ urlreverse('ietf.meeting.views.upcoming_ical'),
+ ):
+ for querystring in (
+ '?%s=1' % quote(payload),
+ '?show=%s' % quote(payload),
+ '?unknown=%s' % quote(payload),
+ ):
+ r = self.client.get(url + querystring)
+ self.assertEqual(r.status_code, 200, 'Expected %s%s to be accepted' % (url, querystring))
+ self.assertNotIn(payload, r.content.decode('utf-8'))
+
def do_ical_filter_test(self, meeting, querystring, expected_session_summaries):
url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number})
r = self.client.get(url + querystring)
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index 913c8bd3021..e2a15d3e8aa 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -2656,7 +2656,11 @@ def agenda_ical(request, num=None, acronym=None, session_id=None):
try:
filt_params = parse_agenda_filter_params(request.GET)
except ValueError as e:
- return HttpResponseBadRequest(str(e))
+ # Defensive only - parse_agenda_filter_params ignores unrecognized parameters and
+ # does not raise. Log the detail rather than reflecting it: the query string is
+ # attacker-controlled and HttpResponseBadRequest serves unescaped text/html.
+ log("Invalid agenda filter parameters in agenda_ical: %s" % e)
+ return HttpResponseBadRequest("Invalid agenda filter parameters")
if meeting.type_id == "ietf":
return agenda_ical_ietf(meeting, filt_params, acronym, session_id)
@@ -4568,8 +4572,10 @@ def upcoming_ical(request):
try:
filter_params = parse_agenda_filter_params(request.GET)
except ValueError as e:
- return HttpResponseBadRequest(str(e))
-
+ # Defensive only - see the corresponding handler in agenda_ical.
+ log("Invalid agenda filter parameters in upcoming_ical: %s" % e)
+ return HttpResponseBadRequest("Invalid agenda filter parameters")
+
today = datetime_today()
# get meetings starting 7 days ago -- we'll filter out sessions in the past further down
diff --git a/ietf/sync/tasks.py b/ietf/sync/tasks.py
index 3af5cb8984d..24d3c77b3be 100644
--- a/ietf/sync/tasks.py
+++ b/ietf/sync/tasks.py
@@ -299,6 +299,89 @@ def refresh_rfc_index_task():
mark_rfcindex_as_processed(new_processed_time)
+# Human-readable labels for the RPC publication queue "Status", mirroring the
+# ietf-tools/queue website (website/app/utils/queue.ts, renderAssignmentsByRoles)
+# so the datatracker shows the same status text that appears at
+# https://queue.rfc-editor.org/. The queue "Status" is not a stored field; it is
+# derived from the active assignment roles, pending activities, blocking reasons
+# and IANA status carried in the purple pubq queue payload.
+RPC_QUEUE_ROLE_LABELS = {
+ "first_editor": "In Progress (First Edit)",
+ "second_editor": "In Progress (Second Edit)",
+ "final_review_editor": "In Final Review",
+}
+# Roles the queue site does not surface in the Status column.
+RPC_QUEUE_HIDDEN_ROLES = {"ref_checker", "publisher"}
+
+
+def _humanize_slug(slug):
+ return slug.replace("_", " ")
+
+
+def _rpc_role_label(role):
+ return RPC_QUEUE_ROLE_LABELS.get(role, _humanize_slug(role))
+
+
+def _rpc_blocking_reason_label(name):
+ # Special case mirrored from the queue site's humanFriendlyBlockingReason().
+ if name == "Reference: First Edit Incomplete":
+ return "Author Input Required"
+ return _humanize_slug(name)
+
+
+def format_rpc_queue_status(obj):
+ """Render the RPC publication queue "Status" for a single queue entry.
+
+ Mirrors renderAssignmentsByRoles() from the ietf-tools/queue website so the
+ datatracker presents the same status text. ``obj`` is one entry of the purple
+ pubq queue payload. Roles, pending activities and blocking reasons are sorted
+ so the result is stable (a change to the string is what triggers a new
+ RpcAssignmentDocEvent).
+ """
+ roles = {
+ a["role"] for a in (obj.get("assignment_set") or []) if a.get("role")
+ }
+ is_blocked = "blocked" in roles
+
+ parts = []
+
+ # IANA hold: iana_status "not_completed" while a first_editor is assigned.
+ iana_status = obj.get("iana_status") or {}
+ if iana_status.get("slug") == "not_completed" and "first_editor" in roles:
+ parts.append("IANA hold")
+
+ # Pending activities (only when not blocked): "Awaiting ", skipping any
+ # role that is already a current assignment. Note the queue site does NOT hide
+ # ref_checker/publisher here (only for current-role badges below), so e.g.
+ # "Awaiting Reference Checker" can appear.
+ if not is_blocked:
+ for activity in sorted(
+ obj.get("pending_activities") or [],
+ key=lambda a: (a.get("name") or a.get("slug") or ""),
+ ):
+ slug = activity.get("slug")
+ if not slug or slug in roles:
+ continue
+ parts.append(f"Awaiting {activity.get('name') or _humanize_slug(slug)}")
+
+ # Current assignment roles (ref_checker/publisher hidden). Blocking reason
+ # names are appended to the "blocked" role.
+ blocking_names = sorted(
+ _rpc_blocking_reason_label(br["reason"]["name"])
+ for br in (obj.get("blocking_reasons") or [])
+ if br.get("reason", {}).get("name")
+ )
+ for role in sorted(roles - RPC_QUEUE_HIDDEN_ROLES):
+ label = _rpc_role_label(role)
+ if role == "blocked" and blocking_names:
+ label += ": " + ", ".join(blocking_names)
+ parts.append(label)
+
+ if not parts:
+ return "Awaiting Editor Assignment"
+ return ", ".join(parts)
+
+
@shared_task
def process_rpc_queue_task(data: list):
in_progress_state = State.objects.get(
@@ -366,16 +449,7 @@ def process_rpc_queue_task(data: list):
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"
+ next_assignments = format_rpc_queue_status(obj)
prev_assignments_event = d.latest_event(
RpcAssignmentDocEvent, type="changed_rpc_assignments"
diff --git a/ietf/sync/tests_tasks.py b/ietf/sync/tests_tasks.py
index 57284b72980..264a5f46bb3 100644
--- a/ietf/sync/tests_tasks.py
+++ b/ietf/sync/tests_tasks.py
@@ -182,7 +182,9 @@ def test_creates_assignment_event_on_first_update(self):
RpcAssignmentDocEvent, type="changed_rpc_assignments"
)
self.assertIsNotNone(event)
- self.assertEqual(event.assignments, "first_editor, second_editor")
+ self.assertEqual(
+ event.assignments, "In Progress (First Edit), In Progress (Second Edit)"
+ )
def test_no_assignment_event_when_unchanged(self):
"""No new RpcAssignmentDocEvent when assignments match the last recorded ones."""
@@ -192,8 +194,8 @@ def test_no_assignment_event_when_unchanged(self):
rev=draft.rev,
by=self.system,
type="changed_rpc_assignments",
- assignments="first_editor",
- desc="RPC status changed to first_editor",
+ assignments="In Progress (First Edit)",
+ desc="RPC status changed to In Progress (First Edit)",
)
events_before = RpcAssignmentDocEvent.objects.filter(doc=draft).count()
@@ -211,8 +213,8 @@ def test_assignment_desc_includes_previous_assignments(self):
rev=draft.rev,
by=self.system,
type="changed_rpc_assignments",
- assignments="first_editor",
- desc="RPC status changed to first_editor",
+ assignments="In Progress (First Edit)",
+ desc="RPC status changed to In Progress (First Edit)",
)
tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["second_editor"])])
@@ -220,7 +222,7 @@ def test_assignment_desc_includes_previous_assignments(self):
event = draft.latest_event(
RpcAssignmentDocEvent, type="changed_rpc_assignments"
)
- self.assertIn("from first_editor", event.desc)
+ self.assertIn("from In Progress (First Edit)", event.desc)
def test_blocking_reasons_appended_to_assignments(self):
"""Blocking reason names are appended after ':' in the assignment string, sorted."""
@@ -257,7 +259,9 @@ def test_roles_sorted_in_assignment_string(self):
event = draft.latest_event(
RpcAssignmentDocEvent, type="changed_rpc_assignments"
)
- self.assertEqual(event.assignments, "first_editor, second_editor")
+ self.assertEqual(
+ event.assignments, "In Progress (First Edit), In Progress (Second Edit)"
+ )
def test_empty_roles_uses_awaiting_editor_assignment(self):
"""Empty assignment_set records 'Awaiting Editor Assignment' rather than an empty string."""
@@ -384,8 +388,8 @@ def test_auth48_url_created_when_assignments_unchanged(self):
rev=draft.rev,
by=self.system,
type="changed_rpc_assignments",
- assignments="first_editor",
- desc="RPC status changed to first_editor",
+ assignments="In Progress (First Edit)",
+ desc="RPC status changed to In Progress (First Edit)",
)
tasks.process_rpc_queue_task(
@@ -420,8 +424,8 @@ def test_auth48_url_deleted_when_assignments_unchanged(self):
rev=draft.rev,
by=self.system,
type="changed_rpc_assignments",
- assignments="first_editor",
- desc="RPC status changed to first_editor",
+ assignments="In Progress (First Edit)",
+ desc="RPC status changed to In Progress (First Edit)",
)
tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])])
@@ -477,6 +481,151 @@ def test_docs_in_queue_retain_rfceditor_state(self):
self.assertIsNotNone(draft.get_state("draft-rfceditor"))
+class FormatRpcQueueStatusTests(TestCase):
+ """Unit tests for the queue "Status" renderer, mirroring the ietf-tools/queue site."""
+
+ def test_editor_roles_get_friendly_labels(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {"assignment_set": [{"role": "first_editor"}]}
+ ),
+ "In Progress (First Edit)",
+ )
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {"assignment_set": [{"role": "final_review_editor"}]}
+ ),
+ "In Final Review",
+ )
+
+ def test_roles_sorted_and_joined(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {"assignment_set": [{"role": "second_editor"}, {"role": "first_editor"}]}
+ ),
+ "In Progress (First Edit), In Progress (Second Edit)",
+ )
+
+ def test_ref_checker_and_publisher_hidden(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [
+ {"role": "ref_checker"},
+ {"role": "publisher"},
+ {"role": "first_editor"},
+ ]
+ }
+ ),
+ "In Progress (First Edit)",
+ )
+
+ def test_pending_activities_awaiting(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [],
+ "pending_activities": [{"slug": "first_editor", "name": "First editor"}],
+ }
+ ),
+ "Awaiting First editor",
+ )
+
+ def test_pending_ref_checker_and_publisher_are_shown(self):
+ # ref_checker/publisher are hidden only as current-role badges, not as
+ # pending activities (matches the queue site).
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [],
+ "pending_activities": [
+ {"slug": "ref_checker", "name": "Reference Checker"},
+ {"slug": "publisher", "name": "Publisher"},
+ ],
+ }
+ ),
+ "Awaiting Publisher, Awaiting Reference Checker",
+ )
+
+ def test_pending_activity_skipped_when_already_assigned(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [{"role": "first_editor"}],
+ "pending_activities": [{"slug": "first_editor", "name": "First editor"}],
+ }
+ ),
+ "In Progress (First Edit)",
+ )
+
+ def test_iana_hold(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [{"role": "first_editor"}],
+ "iana_status": {"slug": "not_completed"},
+ }
+ ),
+ "IANA hold, In Progress (First Edit)",
+ )
+
+ def test_iana_hold_only_when_first_editor_present(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [{"role": "second_editor"}],
+ "iana_status": {"slug": "not_completed"},
+ }
+ ),
+ "In Progress (Second Edit)",
+ )
+
+ def test_blocked_with_reasons_sorted(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [{"role": "blocked"}],
+ "blocking_reasons": [
+ {"reason": {"name": "Stream Hold"}},
+ {"reason": {"name": "Manual Hold"}},
+ ],
+ }
+ ),
+ "blocked: Manual Hold, Stream Hold",
+ )
+
+ def test_blocking_reason_special_case(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [{"role": "blocked"}],
+ "blocking_reasons": [
+ {"reason": {"name": "Reference: First Edit Incomplete"}}
+ ],
+ }
+ ),
+ "blocked: Author Input Required",
+ )
+
+ def test_blocked_suppresses_pending_activities(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status(
+ {
+ "assignment_set": [{"role": "blocked"}],
+ "pending_activities": [{"slug": "first_editor", "name": "First editor"}],
+ "blocking_reasons": [],
+ }
+ ),
+ "blocked",
+ )
+
+ def test_empty_is_awaiting_editor_assignment(self):
+ self.assertEqual(
+ tasks.format_rpc_queue_status({"assignment_set": []}),
+ "Awaiting Editor Assignment",
+ )
+
+
class UpdateErrataFromRfcEditorTaskTests(TestCase):
@mock.patch("ietf.sync.tasks.update_rfc_json_task.delay")
@mock.patch("ietf.sync.tasks.update_errata_from_rfceditor")
diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html
index 57194144989..a39fdf6eb73 100644
--- a/ietf/templates/doc/document_draft.html
+++ b/ietf/templates/doc/document_draft.html
@@ -583,13 +583,13 @@
- RFC Editor state
+ RFC Editor status
|
|
- {{ rfc_editor_state }}
+ {{ rfc_editor_queue_status|default:rfc_editor_state }}
|
diff --git a/ietf/templates/doc/state_help.html b/ietf/templates/doc/state_help.html
index 606e13cbacf..3a61e048996 100644
--- a/ietf/templates/doc/state_help.html
+++ b/ietf/templates/doc/state_help.html
@@ -14,6 +14,37 @@ {{ title }}
href="{% static 'ietf/images/iesg-draft-state-diagram.png' %}">View diagram
{% endif %}
+ {% if queue_status_values %}
+ Queue status
+
+ The RFC Editor state shown for a document in the publication queue is its
+ Status in the RFC Production Center's publication queue, matching
+ what is shown at
+ the RFC Editor publication queue.
+ This status is derived from the document's current editor assignments, pending
+ activities, blocking reasons and IANA status, so it can take the following forms.
+ (Processing labels such as github, markdown and
+ Expedited shown on the publication queue are not part of this status.)
+
+
+
+
+ | Status |
+ Description |
+
+
+
+ {% for value, description in queue_status_values %}
+
+ | {{ value }} |
+ {{ description }} |
+
+ {% endfor %}
+
+
+ Legacy states
+ {{ legacy_states_note }}
+ {% endif %}
diff --git a/ietf/templates/sync/rfc-index.txt b/ietf/templates/sync/rfc-index.txt
index 0f01ddfa905..050afd130e7 100644
--- a/ietf/templates/sync/rfc-index.txt
+++ b/ietf/templates/sync/rfc-index.txt
@@ -21,9 +21,10 @@ or
For example:
- 1129 Internet Time Synchronization: The Network Time Protocol. D.L.
- Mills. October 1989. (Format: TXT, PS, PDF, HTML) (Also RFC1119)
- (Status: INFORMATIONAL) (DOI: 10.17487/RFC1129)
+ 9915 Dynamic Host Configuration Protocol for IPv6 (DHCPv6). T. Mrugalski,
+ B. Volz, M. Richardson, S. Jiang, T. Winters. January 2026. (Format:
+ HTML, TXT, PDF, XML) (Obsoletes RFC8415) (Also STD102) (Status:
+ INTERNET STANDARD) (DOI: 10.17487/RFC9915)
Key to citations:
diff --git a/ietf/utils/test_runner.py b/ietf/utils/test_runner.py
index a23416e87f2..0a46fdf807a 100644
--- a/ietf/utils/test_runner.py
+++ b/ietf/utils/test_runner.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2009-2025, All Rights Reserved
+# Copyright The IETF Trust 2009-2026, All Rights Reserved
# -*- coding: utf-8 -*-
#
# Portion Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies).
@@ -1036,7 +1036,7 @@ def validate(self, testcase):
with tempfile.NamedTemporaryFile() as stdout:
subprocess.run(
[
- "yarn",
+ "npx",
"html-validate",
"--formatter=json",
"--config=" + self.config_file[kind].name,
diff --git a/k8s/datatracker.yaml b/k8s/datatracker.yaml
index 5183893bc8c..2a96ab63bce 100644
--- a/k8s/datatracker.yaml
+++ b/k8s/datatracker.yaml
@@ -3,7 +3,7 @@ kind: Deployment
metadata:
name: datatracker
spec:
- replicas: 1
+ replicas: 2
revisionHistoryLimit: 2
selector:
matchLabels:
diff --git a/playwright/package-lock.json b/playwright/package-lock.json
index c065d20389a..3f354187450 100644
--- a/playwright/package-lock.json
+++ b/playwright/package-lock.json
@@ -14,8 +14,8 @@
"slugify": "1.6.9"
},
"devDependencies": {
- "@playwright/test": "1.61.1",
- "npm-check-updates": "22.2.9"
+ "@playwright/test": "1.62.1",
+ "npm-check-updates": "23.0.1"
}
},
"node_modules/@faker-js/faker": {
@@ -35,19 +35,18 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.61.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
- "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
- "license": "Apache-2.0",
"dependencies": {
- "playwright": "1.61.1"
+ "playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
}
},
"node_modules/fsevents": {
@@ -56,7 +55,6 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -86,50 +84,47 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/npm-check-updates": {
- "version": "22.2.9",
- "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-22.2.9.tgz",
- "integrity": "sha512-DVeZ0KirHfliSsHuR2o7cHE+tW439sVHfJjF6cGWeDiY0Wyl3BI/jS4zV0eixtcMOquFbcF1Su/FsxOvk5MoYA==",
+ "version": "23.0.1",
+ "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-23.0.1.tgz",
+ "integrity": "sha512-e4hu3Rq4waj7SnhzvqAc5UG557mfP5e3JIFFQd7Fg3RiRkJl8yR90NFoZ1GSWUT3S/QacJndJb+7dLQBPeH+iA==",
"dev": true,
- "license": "Apache-2.0",
"bin": {
"ncu": "build/cli.js",
"npm-check-updates": "build/cli.js"
},
"engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0",
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/playwright": {
- "version": "1.61.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
- "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
- "license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.61.1"
+ "playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
- "version": "1.61.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
- "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
- "license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
}
},
"node_modules/seedrandom": {
@@ -154,12 +149,12 @@
"integrity": "sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg=="
},
"@playwright/test": {
- "version": "1.61.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
- "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"requires": {
- "playwright": "1.61.1"
+ "playwright": "1.62.1"
}
},
"fsevents": {
@@ -185,25 +180,25 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"npm-check-updates": {
- "version": "22.2.9",
- "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-22.2.9.tgz",
- "integrity": "sha512-DVeZ0KirHfliSsHuR2o7cHE+tW439sVHfJjF6cGWeDiY0Wyl3BI/jS4zV0eixtcMOquFbcF1Su/FsxOvk5MoYA==",
+ "version": "23.0.1",
+ "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-23.0.1.tgz",
+ "integrity": "sha512-e4hu3Rq4waj7SnhzvqAc5UG557mfP5e3JIFFQd7Fg3RiRkJl8yR90NFoZ1GSWUT3S/QacJndJb+7dLQBPeH+iA==",
"dev": true
},
"playwright": {
- "version": "1.61.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
- "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"requires": {
"fsevents": "2.3.2",
- "playwright-core": "1.61.1"
+ "playwright-core": "1.62.1"
}
},
"playwright-core": {
- "version": "1.61.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
- "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true
},
"seedrandom": {
diff --git a/playwright/package.json b/playwright/package.json
index 63d78a4cb50..d33dca96930 100644
--- a/playwright/package.json
+++ b/playwright/package.json
@@ -16,7 +16,7 @@
"slugify": "1.6.9"
},
"devDependencies": {
- "@playwright/test": "1.61.1",
- "npm-check-updates": "22.2.9"
+ "@playwright/test": "1.62.1",
+ "npm-check-updates": "23.0.1"
}
}
diff --git a/requirements.txt b/requirements.txt
index 485e4eb4a3f..9023aa5436e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -85,7 +85,7 @@ scout-apm>=3.4.0
selenium>=4.34.2
tblib>=3.1.0 # So that the django test runner provides tracebacks
tlds>=2022042700 # Used to teach bleach about which TLDs currently exist
-tqdm>=4.67.1
+tqdm>=4.70.0
unidecode>=1.4.0
urllib3>=2.5.0
weasyprint>=66.0