diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6787afce11..fca50fc4bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -123,6 +123,9 @@ jobs: if: ${{ !failure() && !cancelled() }} needs: [tests, prepare] runs-on: ubuntu-latest + permissions: + contents: write + packages: write env: SHOULD_DEPLOY: ${{needs.prepare.outputs.should_deploy}} PKG_VERSION: ${{needs.prepare.outputs.pkg_version}} @@ -154,13 +157,17 @@ jobs: - name: Make Release Build env: DEBIAN_FRONTEND: noninteractive + BROWSERSLIST_IGNORE_OLD_DATA: 1 run: | echo "PKG_VERSION: $PKG_VERSION" echo "GITHUB_SHA: $GITHUB_SHA" echo "GITHUB_REF_NAME: $GITHUB_REF_NAME" - echo "Running build script..." - chmod +x ./dev/deploy/build.sh - sh ./dev/deploy/build.sh + echo "Running frontend build script..." + echo "Compiling native node packages..." + yarn rebuild + echo "Packaging static assets..." + yarn build --base=https://static.ietf.org/dt/$PKG_VERSION/ + yarn legacy:build echo "Setting version $PKG_VERSION..." sed -i -r -e "s|^__version__ += '.*'$|__version__ = '$PKG_VERSION'|" ietf/__init__.py sed -i -r -e "s|^__release_hash__ += '.*'$|__release_hash__ = '$GITHUB_SHA'|" ietf/__init__.py @@ -178,7 +185,7 @@ jobs: run: | echo "Build release tarball..." mkdir -p /home/runner/work/release - tar -czf /home/runner/work/release/release.tar.gz -X dev/deploy/exclude-patterns.txt . + tar -czf /home/runner/work/release/release.tar.gz -X dev/build/exclude-patterns.txt . - name: Collect + Push Statics env: @@ -189,10 +196,46 @@ jobs: AWS_ENDPOINT_URL: ${{ secrets.CF_R2_ENDPOINT }} run: | echo "Collecting statics..." - docker run --rm --name collectstatics -v $(pwd):/workspace ghcr.io/ietf-tools/datatracker-app-base:latest sh dev/deploy/collectstatics.sh + docker run --rm --name collectstatics -v $(pwd):/workspace ghcr.io/ietf-tools/datatracker-app-base:latest sh dev/build/collectstatics.sh echo "Pushing statics..." cd static aws s3 sync . s3://static/dt/$PKG_VERSION --only-show-errors + + - name: Augment dockerignore for docker image build + env: + DEBIAN_FRONTEND: noninteractive + run: | + cat >> .dockerignore <=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "datatracker.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml new file mode 100644 index 0000000000..f1bdca0ad2 --- /dev/null +++ b/helm/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{include "datatracker.fullname" .}} + labels: {{- include "datatracker.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- range $key, $value := . }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +spec: + type: {{.Values.service.type}} + ports: + - port: {{ default "80" .Values.service.port}} + targetPort: http + protocol: TCP + name: http + selector: {{- include "datatracker.selectorLabels" . | nindent 4}} \ No newline at end of file diff --git a/helm/templates/serviceaccount.yaml b/helm/templates/serviceaccount.yaml new file mode 100644 index 0000000000..475fcd51f7 --- /dev/null +++ b/helm/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "datatracker.serviceAccountName" . }} + labels: + {{- include "datatracker.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end -}} \ No newline at end of file diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000000..92efbce9dd --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,118 @@ +# Default values for datatracker. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: "ghcr.io/ietf-tools/datatracker" + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + # tag: "v1.1.0" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +livenessProbe: + httpGet: + path: /healthz + port: http + +readinessProbe: + httpGet: + path: /healthz + port: http + +startupProbe: + initialDelaySeconds: 15 + periodSeconds: 5 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 60 + httpGet: + path: /healthz + port: http + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: datatracker.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} \ No newline at end of file diff --git a/ietf/api/ietf_utils.py b/ietf/api/ietf_utils.py index 06b9d76aff..50767a5afd 100644 --- a/ietf/api/ietf_utils.py +++ b/ietf/api/ietf_utils.py @@ -2,14 +2,75 @@ # This is not utils.py because Tastypie implicitly consumes ietf.api.utils. # See ietf.api.__init__.py for details. +from functools import wraps +from typing import Callable, Optional, Union from django.conf import settings +from django.http import HttpResponseForbidden + def is_valid_token(endpoint, token): # This is where we would consider integration with vault # Settings implementation for now. if hasattr(settings, "APP_API_TOKENS"): token_store = settings.APP_API_TOKENS - if endpoint in token_store and token in token_store[endpoint]: - return True + if endpoint in token_store: + endpoint_tokens = token_store[endpoint] + # Be sure endpoints is a list or tuple so we don't accidentally use substring matching! + if not isinstance(endpoint_tokens, (list, tuple)): + endpoint_tokens = [endpoint_tokens] + if token in endpoint_tokens: + return True return False + + +def requires_api_token(func_or_endpoint: Optional[Union[Callable, str]] = None): + """Validate API token before executing the wrapped method + + Usage: + * Basic: endpoint defaults to the qualified name of the wrapped method. E.g., in ietf.api.views, + + @requires_api_token + def my_view(request): + ... + + will require a token for "ietf.api.views.my_view" + + * Custom endpoint: specify the endpoint explicitly + + @requires_api_token("ietf.api.views.some_other_thing") + def my_view(request): + ... + + will require a token for "ietf.api.views.some_other_thing" + """ + + def decorate(f): + if _endpoint is None: + fname = getattr(f, "__qualname__", None) + if fname is None: + raise TypeError( + "Cannot automatically decorate function that does not support __qualname__. " + "Explicitly set the endpoint." + ) + endpoint = "{}.{}".format(f.__module__, fname) + else: + endpoint = _endpoint + + @wraps(f) + def wrapped(request, *args, **kwargs): + authtoken = request.META.get("HTTP_X_API_KEY", None) + if authtoken is None or not is_valid_token(endpoint, authtoken): + return HttpResponseForbidden() + return f(request, *args, **kwargs) + + return wrapped + + # Magic to allow decorator to be used with or without parentheses + if callable(func_or_endpoint): + func = func_or_endpoint + _endpoint = None + return decorate(func) + else: + _endpoint = func_or_endpoint + return decorate diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 3d3e3ac121..a495accc31 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -4,6 +4,7 @@ import datetime import json import html +import mock import os import sys @@ -12,7 +13,8 @@ from django.apps import apps from django.conf import settings -from django.test import Client +from django.http import HttpResponseForbidden +from django.test import Client, RequestFactory from django.test.utils import override_settings from django.urls import reverse as urlreverse from django.utils import timezone @@ -38,6 +40,8 @@ from ietf.utils.models import DumpInfo from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects +from .ietf_utils import is_valid_token, requires_api_token + OMITTED_APPS = ( 'ietf.secr.meetings', 'ietf.secr.proceedings', @@ -780,7 +784,74 @@ def test_api_get_session_matherials_no_agenda_meeting_url(self): url = urlreverse('ietf.meeting.views.api_get_session_materials', kwargs={'session_id': session.pk}) r = self.client.get(url) self.assertEqual(r.status_code, 200) + + @override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]}) + @mock.patch("ietf.api.views.DraftAliasGenerator") + def test_draft_aliases(self, mock): + mock.return_value = (("alias1", ("a1", "a2")), ("alias2", ("a3", "a4"))) + url = urlreverse("ietf.api.views.draft_aliases") + 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") + self.assertEqual( + json.loads(r.content), + { + "aliases": [ + {"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]}, + {"alias": "alias2", "domains": ["ietf"], "addresses": ["a3", "a4"]}, + ]} + ) + # some invalid cases + self.assertEqual( + self.client.get(url, headers={}).status_code, + 403, + ) + self.assertEqual( + self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code, + 405, + ) + @override_settings(APP_API_TOKENS={"ietf.api.views.email_aliases": ["valid-token"]}) + @mock.patch("ietf.api.views.GroupAliasGenerator") + def test_group_aliases(self, mock): + mock.return_value = (("alias1", ("ietf",), ("a1", "a2")), ("alias2", ("ietf", "iab"), ("a3", "a4"))) + url = urlreverse("ietf.api.views.group_aliases") + 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") + self.assertEqual( + json.loads(r.content), + { + "aliases": [ + {"alias": "alias1", "domains": ["ietf"], "addresses": ["a1", "a2"]}, + {"alias": "alias2", "domains": ["ietf", "iab"], "addresses": ["a3", "a4"]}, + ]} + ) + # some invalid cases + self.assertEqual( + self.client.get(url, headers={}).status_code, + 403, + ) + self.assertEqual( + self.client.get(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "something-else"}).status_code, + 403, + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code, + 405, + ) class DirectAuthApiTests(TestCase): @@ -1133,3 +1204,85 @@ def test_no_such_document(self): url = urlreverse(self.target_view, kwargs={'name': name}) r = self.client.get(url) self.assertEqual(r.status_code, 404) + + +class TokenTests(TestCase): + @override_settings(APP_API_TOKENS={"known.endpoint": ["token in a list"], "oops": "token as a str"}) + def test_is_valid_token(self): + # various invalid cases + self.assertFalse(is_valid_token("unknown.endpoint", "token in a list")) + self.assertFalse(is_valid_token("known.endpoint", "token")) + self.assertFalse(is_valid_token("known.endpoint", "token as a str")) + self.assertFalse(is_valid_token("oops", "token")) + self.assertFalse(is_valid_token("oops", "token in a list")) + # the only valid cases + self.assertTrue(is_valid_token("known.endpoint", "token in a list")) + self.assertTrue(is_valid_token("oops", "token as a str")) + + @mock.patch("ietf.api.ietf_utils.is_valid_token") + def test_requires_api_token(self, mock_is_valid_token): + called = False + + @requires_api_token + def fn_to_wrap(request, *args, **kwargs): + nonlocal called + called = True + return request, args, kwargs + + req_factory = RequestFactory() + arg = object() + kwarg = object() + + # No X-Api-Key header + mock_is_valid_token.return_value = False + val = fn_to_wrap( + req_factory.get("/some/url", headers={}), + arg, + kwarg=kwarg, + ) + self.assertTrue(isinstance(val, HttpResponseForbidden)) + self.assertFalse(mock_is_valid_token.called) + self.assertFalse(called) + + # Bad X-Api-Key header (not resetting the mock, it was not used yet) + val = fn_to_wrap( + req_factory.get("/some/url", headers={"X-Api-Key": "some-value"}), + arg, + kwarg=kwarg, + ) + self.assertTrue(isinstance(val, HttpResponseForbidden)) + self.assertTrue(mock_is_valid_token.called) + self.assertEqual( + mock_is_valid_token.call_args[0], + (fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"), + ) + self.assertFalse(called) + + # Valid header + mock_is_valid_token.reset_mock() + mock_is_valid_token.return_value = True + request = req_factory.get("/some/url", headers={"X-Api-Key": "some-value"}) + # Bad X-Api-Key header (not resetting the mock, it was not used yet) + val = fn_to_wrap( + request, + arg, + kwarg=kwarg, + ) + self.assertEqual(val, (request, (arg,), {"kwarg": kwarg})) + self.assertTrue(mock_is_valid_token.called) + self.assertEqual( + mock_is_valid_token.call_args[0], + (fn_to_wrap.__module__ + "." + fn_to_wrap.__qualname__, "some-value"), + ) + self.assertTrue(called) + + # Test the endpoint setting + @requires_api_token("endpoint") + def another_fn_to_wrap(request): + return "yep" + + val = another_fn_to_wrap(request) + self.assertEqual( + mock_is_valid_token.call_args[0], + ("endpoint", "some-value"), + ) diff --git a/ietf/api/urls.py b/ietf/api/urls.py index 7ee55cf708..107bd398d9 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -22,8 +22,12 @@ url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()), # # --- Custom API endpoints, sorted alphabetically --- + # Email alias information for drafts + url(r'^doc/draft-aliases/$', api_views.draft_aliases), # GPRD: export of personal information for the logged-in person url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()), + # Email alias information for groups + url(r'^group/group-aliases/$', api_views.group_aliases), # Let IESG members set positions programmatically url(r'^iesg/position', views_ballot.api_set_position), # Let Meetecho set session video URLs diff --git a/ietf/api/views.py b/ietf/api/views.py index 78e3236842..4205aa3153 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -2,42 +2,39 @@ # -*- coding: utf-8 -*- import json -import pytz import re -from jwcrypto.jwk import JWK - +import pytz from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import validate_email -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, JsonResponse from django.shortcuts import render, get_object_or_404 from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.gzip import gzip_page from django.views.generic.detail import DetailView - +from jwcrypto.jwk import JWK from tastypie.exceptions import BadRequest -from tastypie.utils.mime import determine_format, build_content_type -from tastypie.utils import is_valid_jsonp_callback_value from tastypie.serializers import Serializer - -import debug # pyflakes:ignore +from tastypie.utils import is_valid_jsonp_callback_value +from tastypie.utils.mime import determine_format, build_content_type import ietf -from ietf.person.models import Person, Email 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.api.ietf_utils import is_valid_token -from ietf.doc.utils import fuzzy_find_documents -from ietf.ietfauth.views import send_account_creation_email +from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents +from ietf.group.utils import GroupAliasGenerator from ietf.ietfauth.utils import role_required +from ietf.ietfauth.views import send_account_creation_email from ietf.meeting.models import Meeting from ietf.nomcom.models import Volunteer, NomCom +from ietf.person.models import Person, Email from ietf.stats.models import MeetingRegistration from ietf.utils import log from ietf.utils.decorators import require_api_key @@ -453,3 +450,41 @@ def directauth(request): else: return HttpResponse(status=405) + + +@requires_api_token("ietf.api.views.email_aliases") +@csrf_exempt +def draft_aliases(request): + if request.method == "GET": + return JsonResponse( + { + "aliases": [ + { + "alias": alias, + "domains": ["ietf"], + "addresses": address_list, + } + for alias, address_list in DraftAliasGenerator() + ] + } + ) + return HttpResponse(status=405) + + +@requires_api_token("ietf.api.views.email_aliases") +@csrf_exempt +def group_aliases(request): + if request.method == "GET": + return JsonResponse( + { + "aliases": [ + { + "alias": alias, + "domains": domains, + "addresses": address_list, + } + for alias, domains, address_list in GroupAliasGenerator() + ] + } + ) + return HttpResponse(status=405) diff --git a/ietf/bin/aliases-from-json.py b/ietf/bin/aliases-from-json.py new file mode 100644 index 0000000000..72fcb469f7 --- /dev/null +++ b/ietf/bin/aliases-from-json.py @@ -0,0 +1,99 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# +# Uses only Python standard lib +# + +import argparse +import datetime +import json +import shutil +import stat +import sys + +from pathlib import Path +from tempfile import TemporaryDirectory + +# Default options +POSTCONFIRM_PATH = "/a/postconfirm/wrapper" +VDOMAIN = "virtual.ietf.org" + +# Map from domain label to dns domain +ADOMAINS = { + "ietf": "ietf.org", + "irtf": "irtf.org", + "iab": "iab.org", +} + + +def generate_files(records, adest, vdest, postconfirm, vdomain): + """Generate files from an iterable of records + + If adest or vdest exists as a file, it will be overwritten. If it is a directory, files + with the default names (draft-aliases and draft-virtual) will be created, but existing + files _will not_ be overwritten! + """ + with TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + apath = tmppath / "aliases" + vpath = tmppath / "virtual" + + with apath.open("w") as afile, vpath.open("w") as vfile: + date = datetime.datetime.now(datetime.timezone.utc) + signature = f"# Generated by {Path(__file__).absolute()} at {date}\n" + afile.write(signature) + vfile.write(signature) + vfile.write(f"{vdomain} anything\n") + + for item in records: + alias = item["alias"] + domains = item["domains"] + address_list = item["addresses"] + filtername = f"xfilter-{alias}" + afile.write(f'{filtername + ":":64s} "|{postconfirm} filter expand-{alias} {vdomain}"\n') + for dom in domains: + vfile.write(f"{f'{alias}@{ADOMAINS[dom]}':64s} {filtername}\n") + vfile.write(f"{f'expand-{alias}@{vdomain}':64s} {', '.join(sorted(address_list))}\n") + + perms = stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH + apath.chmod(perms) + vpath.chmod(perms) + shutil.move(apath, adest) + shutil.move(vpath, vdest) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Convert a JSON stream of draft alias definitions into alias / virtual alias files." + ) + parser.add_argument( + "--prefix", + required=True, + help="Prefix for output files. Files will be named -aliases and -virtual." + ) + parser.add_argument( + "--output-dir", + default="./", + type=Path, + help="Destination for output files.", + ) + parser.add_argument( + "--postconfirm", + default=POSTCONFIRM_PATH, + help=f"Full path to postconfirm executable (defaults to {POSTCONFIRM_PATH}", + ) + parser.add_argument( + "--vdomain", + default=VDOMAIN, + help=f"Virtual domain (defaults to {VDOMAIN}_", + ) + args = parser.parse_args() + if not args.output_dir.is_dir(): + sys.stderr.write("Error: output-dir must be a directory") + data = json.load(sys.stdin) + generate_files( + data["aliases"], + adest=args.output_dir / f"{args.prefix}-aliases", + vdest=args.output_dir / f"{args.prefix}-virtual", + postconfirm=args.postconfirm, + vdomain=args.vdomain, + ) diff --git a/ietf/bin/mailman_listinfo.py b/ietf/bin/mailman_listinfo.py deleted file mode 100755 index f7e4cfe4c1..0000000000 --- a/ietf/bin/mailman_listinfo.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/python2.7 -# Copyright The IETF Trust 2022, All Rights Reserved -# Note the shebang. This specifically targets deployment on IETFA and intends to use its system python2.7. - -# This is an adaptor to pull information out of Mailman2 using its python libraries (which are only available for python2). -# It is NOT django code, and does not have access to django.conf.settings. - -import json -import sys - -from collections import defaultdict - -def main(): - - sys.path.append('/usr/lib/mailman') - - have_mailman = False - try: - from Mailman import Utils - from Mailman import MailList - from Mailman import MemberAdaptor - have_mailman = True - except ImportError: - pass - - - if not have_mailman: - sys.stderr.write("Could not import mailman modules -- skipping import of mailman list info") - sys.exit() - - names = list(Utils.list_names()) - - # need to emit dict of names, each name has an mlist, and each mlist has description, advertised, and members (calculated as below) - result = defaultdict(dict) - for name in names: - mlist = MailList.MailList(name, lock=False) - result[name] = dict() - result[name]['internal_name'] = mlist.internal_name() - result[name]['real_name'] = mlist.real_name - result[name]['description'] = mlist.description # Not attempting to change encoding - result[name]['advertised'] = mlist.advertised - result[name]['members'] = list() - if mlist.advertised: - members = mlist.getRegularMemberKeys() + mlist.getDigestMemberKeys() - members = set([ m for m in members if mlist.getDeliveryStatus(m) == MemberAdaptor.ENABLED ]) - result[name]['members'] = list(members) - json.dump(result, sys.stdout) - -if __name__ == "__main__": - main() diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 536e3e3493..af4843fc41 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -280,6 +280,19 @@ def _get_ref(self, meeting=None, meeting_doc_refs=settings.MEETING_DOC_HREFS): info = dict(doc=self) href = format.format(**info) + + # For slides that are not meeting-related, we need to know the file extension. + # Assume we have access to the same files as settings.DOC_HREFS["slides"] and + # see what extension is available + if self.type_id == "slides" and not self.meeting_related() and not href.endswith("/"): + filepath = Path(self.get_file_path()) / self.get_base_name() # start with this + if not filepath.exists(): + # Look for other extensions - grab the first one, sorted for stability + for existing in sorted(filepath.parent.glob(f"{filepath.stem}.*")): + filepath = filepath.with_suffix(existing.suffix) + break + href += filepath.suffix # tack on the extension + if href.startswith('/'): href = settings.IDTRACKER_BASE_URL + href self._cached_href = href diff --git a/ietf/doc/tasks.py b/ietf/doc/tasks.py new file mode 100644 index 0000000000..a2e83e9e26 --- /dev/null +++ b/ietf/doc/tasks.py @@ -0,0 +1,56 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# +# Celery task definitions +# +import datetime +import debug # pyflakes:ignore + +from celery import shared_task + +from ietf.utils import log +from ietf.utils.timezone import datetime_today + +from .expire import ( + in_draft_expire_freeze, + get_expired_drafts, + expirable_drafts, + send_expire_notice_for_draft, + expire_draft, + clean_up_draft_files, + get_soon_to_expire_drafts, + send_expire_warning_for_draft, +) +from .models import Document + + +@shared_task +def expire_ids_task(): + try: + if not in_draft_expire_freeze(): + log.log("Expiring drafts ...") + for doc in get_expired_drafts(): + # verify expirability -- it might have changed after get_expired_drafts() was run + # (this whole loop took about 2 minutes on 04 Jan 2018) + # N.B., re-running expirable_drafts() repeatedly is fairly expensive. Where possible, + # it's much faster to run it once on a superset query of the objects you are going + # to test and keep its results. That's not desirable here because it would defeat + # the purpose of double-checking that a document is still expirable when it is actually + # being marked as expired. + if expirable_drafts( + Document.objects.filter(pk=doc.pk) + ).exists() and doc.expires < datetime_today() + datetime.timedelta(1): + send_expire_notice_for_draft(doc) + expire_draft(doc) + log.log(f" Expired draft {doc.name}-{doc.rev}") + + log.log("Cleaning up draft files") + clean_up_draft_files() + except Exception as e: + log.log("Exception in expire-ids: %s" % e) + raise + + +@shared_task +def notify_expirations_task(notify_days=14): + for doc in get_soon_to_expire_drafts(notify_days): + send_expire_warning_for_draft(doc) diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 63953876aa..6d4902542f 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -45,7 +45,7 @@ StatusChangeFactory, DocExtResourceFactory, RgDraftFactory, BcpFactory) from ietf.doc.forms import NotifyForm from ietf.doc.fields import SearchableDocumentsField -from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name +from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name, DraftAliasGenerator from ietf.group.models import Group, Role from ietf.group.factories import GroupFactory, RoleFactory from ietf.ipr.factories import HolderIprDisclosureFactory @@ -2291,6 +2291,7 @@ def testManagementCommand(self): "xfilter-" + doc3.name + ".ad", "xfilter-" + doc3.name + ".authors", "xfilter-" + doc3.name + ".chairs", + "xfilter-" + doc3.name + ".all", "xfilter-" + doc5.name, "xfilter-" + doc5.name + ".authors", "xfilter-" + doc5.name + ".all", @@ -2307,6 +2308,148 @@ def testManagementCommand(self): ]: self.assertNotIn(x, vcontent) + @override_settings(TOOLS_SERVER="tools.example.org", DRAFT_ALIAS_DOMAIN="draft.example.org") + def test_generator_class(self): + """The DraftAliasGenerator should generate the same lists as the old mgmt cmd""" + a_month_ago = (timezone.now() - datetime.timedelta(30)).astimezone(RPC_TZINFO) + a_month_ago = a_month_ago.replace(hour=0, minute=0, second=0, microsecond=0) + ad = RoleFactory( + name_id="ad", group__type_id="area", group__state_id="active" + ).person + shepherd = PersonFactory() + author1 = PersonFactory() + author2 = PersonFactory() + author3 = PersonFactory() + author4 = PersonFactory() + author5 = PersonFactory() + author6 = PersonFactory() + mars = GroupFactory(type_id="wg", acronym="mars") + marschairman = PersonFactory(user__username="marschairman") + mars.role_set.create( + name_id="chair", person=marschairman, email=marschairman.email() + ) + doc1 = IndividualDraftFactory(authors=[author1], shepherd=shepherd.email(), ad=ad) + doc2 = WgDraftFactory( + name="draft-ietf-mars-test", group__acronym="mars", authors=[author2], ad=ad + ) + doc2.notify = f"{doc2.name}.ad@draft.example.org" + doc2.save() + doc3 = WgDraftFactory.create( + name="draft-ietf-mars-finished", + group__acronym="mars", + authors=[author3], + ad=ad, + std_level_id="ps", + states=[("draft", "rfc"), ("draft-iesg", "pub")], + time=a_month_ago, + ) + rfc3 = WgRfcFactory() + DocEventFactory.create(doc=rfc3, type="published_rfc", time=a_month_ago) + doc3.relateddocument_set.create(relationship_id="became_rfc", target=rfc3) + doc4 = WgDraftFactory.create( + authors=[author4, author5], + ad=ad, + std_level_id="ps", + states=[("draft", "rfc"), ("draft-iesg", "pub")], + time=datetime.datetime(2010, 10, 10, tzinfo=ZoneInfo(settings.TIME_ZONE)), + ) + rfc4 = WgRfcFactory() + DocEventFactory.create( + doc=rfc4, + type="published_rfc", + time=datetime.datetime(2010, 10, 10, tzinfo=RPC_TZINFO), + ) + doc4.relateddocument_set.create(relationship_id="became_rfc", target=rfc4) + doc5 = IndividualDraftFactory(authors=[author6]) + + output = [(alias, alist) for alias, alist in DraftAliasGenerator()] + alias_dict = dict(output) + self.assertEqual(len(alias_dict), len(output)) # no duplicate aliases + expected_dict = { + doc1.name: [author1.email_address()], + doc1.name + ".ad": [ad.email_address()], + doc1.name + ".authors": [author1.email_address()], + doc1.name + ".shepherd": [shepherd.email_address()], + doc1.name + + ".all": [ + author1.email_address(), + ad.email_address(), + shepherd.email_address(), + ], + doc2.name: [author2.email_address()], + doc2.name + ".ad": [ad.email_address()], + doc2.name + ".authors": [author2.email_address()], + doc2.name + ".chairs": [marschairman.email_address()], + doc2.name + ".notify": [ad.email_address()], + doc2.name + + ".all": [ + author2.email_address(), + ad.email_address(), + marschairman.email_address(), + ], + doc3.name: [author3.email_address()], + doc3.name + ".ad": [ad.email_address()], + doc3.name + ".authors": [author3.email_address()], + doc3.name + ".chairs": [marschairman.email_address()], + doc3.name + + ".all": [ + author3.email_address(), + ad.email_address(), + marschairman.email_address(), + ], + doc5.name: [author6.email_address()], + doc5.name + ".authors": [author6.email_address()], + doc5.name + ".all": [author6.email_address()], + } + # Sort lists for comparison + self.assertEqual( + {k: sorted(v) for k, v in alias_dict.items()}, + {k: sorted(v) for k, v in expected_dict.items()}, + ) + + @override_settings(TOOLS_SERVER="tools.example.org", DRAFT_ALIAS_DOMAIN="draft.example.org") + def test_get_draft_notify_emails(self): + ad = PersonFactory() + shepherd = PersonFactory() + author = PersonFactory() + doc = DocumentFactory(authors=[author], shepherd=shepherd.email(), ad=ad) + generator = DraftAliasGenerator() + + doc.notify = f"{doc.name}@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [author.email_address()]) + + doc.notify = f"{doc.name}.ad@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [ad.email_address()]) + + doc.notify = f"{doc.name}.shepherd@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [shepherd.email_address()]) + + doc.notify = f"{doc.name}.all@draft.example.org" + doc.save() + self.assertCountEqual( + generator.get_draft_notify_emails(doc), + [ad.email_address(), author.email_address(), shepherd.email_address()] + ) + + doc.notify = f"{doc.name}.notify@draft.example.org" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), []) + + doc.notify = f"{doc.name}.ad@somewhere.example.com" + doc.save() + self.assertCountEqual(generator.get_draft_notify_emails(doc), [f"{doc.name}.ad@somewhere.example.com"]) + + doc.notify = f"somebody@example.com, nobody@example.com, {doc.name}.ad@tools.example.org" + doc.save() + self.assertCountEqual( + generator.get_draft_notify_emails(doc), + ["somebody@example.com", "nobody@example.com", ad.email_address()] + ) + + class EmailAliasesTests(TestCase): def setUp(self): diff --git a/ietf/doc/tests_tasks.py b/ietf/doc/tests_tasks.py new file mode 100644 index 0000000000..931ed438dc --- /dev/null +++ b/ietf/doc/tests_tasks.py @@ -0,0 +1,63 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +import mock + +from ietf.utils.test_utils import TestCase +from ietf.utils.timezone import datetime_today + +from .factories import DocumentFactory +from .models import Document +from .tasks import expire_ids_task, notify_expirations_task + + +class TaskTests(TestCase): + + @mock.patch("ietf.doc.tasks.in_draft_expire_freeze") + @mock.patch("ietf.doc.tasks.get_expired_drafts") + @mock.patch("ietf.doc.tasks.expirable_drafts") + @mock.patch("ietf.doc.tasks.send_expire_notice_for_draft") + @mock.patch("ietf.doc.tasks.expire_draft") + @mock.patch("ietf.doc.tasks.clean_up_draft_files") + def test_expire_ids_task( + self, + clean_up_draft_files_mock, + expire_draft_mock, + send_expire_notice_for_draft_mock, + expirable_drafts_mock, + get_expired_drafts_mock, + in_draft_expire_freeze_mock, + ): + # set up mocks + in_draft_expire_freeze_mock.return_value = False + doc, other_doc = DocumentFactory.create_batch(2) + doc.expires = datetime_today() + get_expired_drafts_mock.return_value = [doc, other_doc] + expirable_drafts_mock.side_effect = [ + Document.objects.filter(pk=doc.pk), + Document.objects.filter(pk=other_doc.pk), + ] + + # call task + expire_ids_task() + + # check results + self.assertTrue(in_draft_expire_freeze_mock.called) + self.assertEqual(expirable_drafts_mock.call_count, 2) + self.assertEqual(send_expire_notice_for_draft_mock.call_count, 1) + self.assertEqual(send_expire_notice_for_draft_mock.call_args[0], (doc,)) + self.assertEqual(expire_draft_mock.call_count, 1) + self.assertEqual(expire_draft_mock.call_args[0], (doc,)) + self.assertTrue(clean_up_draft_files_mock.called) + + # test that an exception is raised + in_draft_expire_freeze_mock.side_effect = RuntimeError + with self.assertRaises(RuntimeError):( + expire_ids_task()) + + @mock.patch("ietf.doc.tasks.send_expire_warning_for_draft") + @mock.patch("ietf.doc.tasks.get_soon_to_expire_drafts") + def test_notify_expirations_task(self, get_drafts_mock, send_warning_mock): + # Set up mocks + get_drafts_mock.return_value = ["sentinel"] + notify_expirations_task() + self.assertEqual(send_warning_mock.call_count, 1) + self.assertEqual(send_warning_mock.call_args[0], ("sentinel",)) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 46ecccc314..ad1c2af223 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -13,7 +13,7 @@ from collections import defaultdict, namedtuple, Counter from dataclasses import dataclass -from typing import Union +from typing import Iterator, Union from zoneinfo import ZoneInfo from django.conf import settings @@ -41,7 +41,7 @@ from ietf.person.models import Person from ietf.review.models import ReviewWish from ietf.utils import draft, log -from ietf.utils.mail import send_mail +from ietf.utils.mail import parseaddr, send_mail from ietf.mailtrigger.utils import gather_address_lists from ietf.utils.timezone import date_today, datetime_from_date, datetime_today, DEADLINE_TZINFO from ietf.utils.xmldraft import XMLDraft @@ -1258,3 +1258,125 @@ def bibxml_for_draft(doc, rev=None): return render_to_string('doc/bibxml.xml', {'name':name, 'doc':doc, 'doc_bibtype':'I-D', 'settings':settings}) + +class DraftAliasGenerator: + days = 2 * 365 + + def get_draft_ad_emails(self, doc): + """Get AD email addresses for the given draft, if any.""" + from ietf.group.utils import get_group_ad_emails # avoid circular import + ad_emails = set() + # If working group document, return current WG ADs + if doc.group and doc.group.acronym != "none": + ad_emails.update(get_group_ad_emails(doc.group)) + # Document may have an explicit AD set + if doc.ad: + ad_emails.add(doc.ad.email_address()) + return ad_emails + + def get_draft_chair_emails(self, doc): + """Get chair email addresses for the given draft, if any.""" + from ietf.group.utils import get_group_role_emails # avoid circular import + chair_emails = set() + if doc.group: + chair_emails.update(get_group_role_emails(doc.group, ["chair", "secr"])) + return chair_emails + + def get_draft_shepherd_email(self, doc): + """Get shepherd email addresses for the given draft, if any.""" + shepherd_email = set() + if doc.shepherd: + shepherd_email.add(doc.shepherd.email_address()) + return shepherd_email + + def get_draft_authors_emails(self, doc): + """Get list of authors for the given draft.""" + author_emails = set() + for author in doc.documentauthor_set.all(): + if author.email and author.email.email_address(): + author_emails.add(author.email.email_address()) + return author_emails + + def get_draft_notify_emails(self, doc): + """Get list of email addresses to notify for the given draft.""" + ad_email_alias_regex = r"^%s.ad@(%s|%s)$" % (doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + all_email_alias_regex = r"^%s.all@(%s|%s)$" % (doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + author_email_alias_regex = r"^%s@(%s|%s)$" % (doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + notify_email_alias_regex = r"^%s.notify@(%s|%s)$" % ( + doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + shepherd_email_alias_regex = r"^%s.shepherd@(%s|%s)$" % ( + doc.name, settings.DRAFT_ALIAS_DOMAIN, settings.TOOLS_SERVER) + notify_emails = set() + if doc.notify: + for e in doc.notify.split(','): + e = e.strip() + if re.search(ad_email_alias_regex, e): + notify_emails.update(self.get_draft_ad_emails(doc)) + elif re.search(author_email_alias_regex, e): + notify_emails.update(self.get_draft_authors_emails(doc)) + elif re.search(shepherd_email_alias_regex, e): + notify_emails.update(self.get_draft_shepherd_email(doc)) + elif re.search(all_email_alias_regex, e): + notify_emails.update(self.get_draft_ad_emails(doc)) + notify_emails.update(self.get_draft_authors_emails(doc)) + notify_emails.update(self.get_draft_shepherd_email(doc)) + elif re.search(notify_email_alias_regex, e): + pass + else: + (name, email) = parseaddr(e) + notify_emails.add(email) + return notify_emails + + def __iter__(self) -> Iterator[tuple[str, list[str]]]: + # Internet-Drafts with active status or expired within self.days + show_since = timezone.now() - datetime.timedelta(days=self.days) + drafts = Document.objects.filter(type_id="draft") + active_drafts = drafts.filter(states__slug='active') + inactive_recent_drafts = drafts.exclude(states__slug='active').filter(expires__gte=show_since) + interesting_drafts = active_drafts | inactive_recent_drafts + + for this_draft in interesting_drafts.distinct().iterator(): + # Omit drafts that became RFCs, unless they were published in the last DEFAULT_YEARS + if this_draft.get_state_slug() == "rfc": + rfc = this_draft.became_rfc() + log.assertion("rfc is not None") + if rfc.latest_event(type='published_rfc').time < show_since: + continue + + alias = this_draft.name + all = set() + + # no suffix and .authors are the same list + emails = self.get_draft_authors_emails(this_draft) + all.update(emails) + if emails: + yield alias, list(emails) + yield alias + ".authors", list(emails) + + # .chairs = group chairs + emails = self.get_draft_chair_emails(this_draft) + if emails: + all.update(emails) + yield alias + ".chairs", list(emails) + + # .ad = sponsoring AD / WG AD (WG document) + emails = self.get_draft_ad_emails(this_draft) + if emails: + all.update(emails) + yield alias + ".ad", list(emails) + + # .notify = notify email list from the Document + emails = self.get_draft_notify_emails(this_draft) + if emails: + all.update(emails) + yield alias + ".notify", list(emails) + + # .shepherd = shepherd email from the Document + emails = self.get_draft_shepherd_email(this_draft) + if emails: + all.update(emails) + yield alias + ".shepherd", list(emails) + + # .all = everything from above + if all: + yield alias + ".all", list(all) diff --git a/ietf/group/tests.py b/ietf/group/tests.py index b11ed8e5fb..66a854000d 100644 --- a/ietf/group/tests.py +++ b/ietf/group/tests.py @@ -20,7 +20,7 @@ from ietf.doc.factories import DocumentFactory, WgDraftFactory, EditorialDraftFactory from ietf.doc.models import DocEvent, RelatedDocument, Document from ietf.group.models import Role, Group -from ietf.group.utils import get_group_role_emails, get_child_group_role_emails, get_group_ad_emails +from ietf.group.utils import get_group_role_emails, get_child_group_role_emails, get_group_ad_emails, GroupAliasGenerator from ietf.group.factories import GroupFactory, RoleFactory from ietf.person.factories import PersonFactory, EmailFactory from ietf.person.models import Person @@ -163,7 +163,7 @@ def testManagementCommand(self): recent = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_month_ago) recentchair = PersonFactory(user__username='recentchair') recent.role_set.create(name_id='chair', person=recentchair, email=recentchair.email()) - wayold = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_decade_ago) + wayold = GroupFactory(type_id='wg', acronym='wayold', parent=area, state_id='conclude', time=a_decade_ago) wayoldchair = PersonFactory(user__username='wayoldchair') wayold.role_set.create(name_id='chair', person=wayoldchair, email=wayoldchair.email()) role2 = RoleFactory(name_id='ad', group__type_id='area', group__acronym='done', group__state_id='conclude') @@ -220,7 +220,7 @@ def testManagementCommand(self): testrgchair.email_address(), testragchair.email_address(), ]])) - self.assertFalse(all([x in vcontent for x in [ + self.assertFalse(any([x in vcontent for x in [ done_ad.email_address(), wayoldchair.email_address(), individual.email_address(), @@ -248,6 +248,64 @@ def testManagementCommand(self): 'xfilter-' + wayold.acronym + '-chairs', ]])) + def test_generator_class(self): + """The GroupAliasGenerator should generate the same lists as the old mgmt cmd""" + # clean out test fixture group roles we don't need for this test + Role.objects.filter( + group__acronym__in=["farfut", "iab", "ietf", "irtf", "ise", "ops", "rsab", "rsoc", "sops"] + ).delete() + + a_month_ago = timezone.now() - datetime.timedelta(30) + a_decade_ago = timezone.now() - datetime.timedelta(3650) + role1 = RoleFactory(name_id='ad', group__type_id='area', group__acronym='myth', group__state_id='active') + area = role1.group + ad = role1.person + mars = GroupFactory(type_id='wg', acronym='mars', parent=area) + marschair = PersonFactory(user__username='marschair') + mars.role_set.create(name_id='chair', person=marschair, email=marschair.email()) + marssecr = PersonFactory(user__username='marssecr') + mars.role_set.create(name_id='secr', person=marssecr, email=marssecr.email()) + ames = GroupFactory(type_id='wg', acronym='ames', parent=area) + ameschair = PersonFactory(user__username='ameschair') + ames.role_set.create(name_id='chair', person=ameschair, email=ameschair.email()) + recent = GroupFactory(type_id='wg', acronym='recent', parent=area, state_id='conclude', time=a_month_ago) + recentchair = PersonFactory(user__username='recentchair') + recent.role_set.create(name_id='chair', person=recentchair, email=recentchair.email()) + wayold = GroupFactory(type_id='wg', acronym='wayold', parent=area, state_id='conclude', time=a_decade_ago) + wayoldchair = PersonFactory(user__username='wayoldchair') + wayold.role_set.create(name_id='chair', person=wayoldchair, email=wayoldchair.email()) + # create a "done" group that should not be included anywhere + RoleFactory(name_id='ad', group__type_id='area', group__acronym='done', group__state_id='conclude') + irtf = Group.objects.get(acronym='irtf') + testrg = GroupFactory(type_id='rg', acronym='testrg', parent=irtf) + testrgchair = PersonFactory(user__username='testrgchair') + testrg.role_set.create(name_id='chair', person=testrgchair, email=testrgchair.email()) + testrag = GroupFactory(type_id='rg', acronym='testrag', parent=irtf) + testragchair = PersonFactory(user__username='testragchair') + testrag.role_set.create(name_id='chair', person=testragchair, email=testragchair.email()) + + output = [(alias, (domains, alist)) for alias, domains, alist in GroupAliasGenerator()] + alias_dict = dict(output) + self.maxDiff = None + self.assertEqual(len(alias_dict), len(output)) # no duplicate aliases + expected_dict = { + area.acronym + "-ads": (["ietf"], [ad.email_address()]), + area.acronym + "-chairs": (["ietf"], [ad.email_address(), marschair.email_address(), marssecr.email_address(), ameschair.email_address()]), + mars.acronym + "-ads": (["ietf"], [ad.email_address()]), + mars.acronym + "-chairs": (["ietf"], [marschair.email_address(), marssecr.email_address()]), + ames.acronym + "-ads": (["ietf"], [ad.email_address()]), + ames.acronym + "-chairs": (["ietf"], [ameschair.email_address()]), + recent.acronym + "-ads": (["ietf"], [ad.email_address()]), + recent.acronym + "-chairs": (["ietf"], [recentchair.email_address()]), + testrg.acronym + "-chairs": (["ietf", "irtf"], [testrgchair.email_address()]), + testrag.acronym + "-chairs": (["ietf", "irtf"], [testragchair.email_address()]), + } + # Sort lists for comparison + self.assertEqual( + {k: (sorted(doms), sorted(addrs)) for k, (doms, addrs) in alias_dict.items()}, + {k: (sorted(doms), sorted(addrs)) for k, (doms, addrs) in expected_dict.items()}, + ) + class GroupRoleEmailTests(TestCase): diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 92b9ac1bd6..36917d3124 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -1,11 +1,12 @@ # Copyright The IETF Trust 2012-2023, All Rights Reserved # -*- coding: utf-8 -*- - +import datetime from pathlib import Path from django.db.models import Q from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe from django.urls import reverse as urlreverse @@ -353,3 +354,74 @@ def update_role_set(group, role_name, new_value, by): e.save() return added, removed + + +class GroupAliasGenerator: + days = 5 * 365 + active_states = ["active", "bof", "proposed"] + group_types = [ + "wg", + "rg", + "rag", + "dir", + "team", + "review", + "program", + "rfcedtyp", + "edappr", + "edwg", + ] # This should become groupfeature driven... + no_ad_group_types = ["rg", "rag", "team", "program", "rfcedtyp", "edappr", "edwg"] + + def __iter__(self): + show_since = timezone.now() - datetime.timedelta(days=self.days) + + # Loop through each group type and build -ads and -chairs entries + for g in self.group_types: + domains = ["ietf"] + if g in ("rg", "rag"): + domains.append("irtf") + if g == "program": + domains.append("iab") + + entries = Group.objects.filter(type=g).all() + active_entries = entries.filter(state__in=self.active_states) + inactive_recent_entries = entries.exclude( + state__in=self.active_states + ).filter(time__gte=show_since) + interesting_entries = active_entries | inactive_recent_entries + + for e in interesting_entries.distinct().iterator(): + name = e.acronym + + # Research groups, teams, and programs do not have -ads lists + if not g in self.no_ad_group_types: + ad_emails = get_group_ad_emails(e) + if ad_emails: + yield name + "-ads", domains, list(ad_emails) + # All group types have -chairs lists + chair_emails = get_group_role_emails(e, ["chair", "secr"]) + if chair_emails: + yield name + "-chairs", domains, list(chair_emails) + + # The area lists include every chair in active working groups in the area + areas = Group.objects.filter(type="area").all() + active_areas = areas.filter(state__in=self.active_states) + for area in active_areas: + name = area.acronym + area_ad_emails = get_group_role_emails(area, ["pre-ad", "ad", "chair"]) + if area_ad_emails: + yield name + "-ads", ["ietf"], list(area_ad_emails) + chair_emails = get_child_group_role_emails(area, ["chair", "secr"]) | area_ad_emails + if chair_emails: + yield name + "-chairs", ["ietf"], list(chair_emails) + + # Other groups with chairs that require Internet-Draft submission approval + gtypes = GroupTypeName.objects.values_list("slug", flat=True) + special_groups = Group.objects.filter( + type__features__req_subm_approval=True, acronym__in=gtypes, state="active" + ) + for group in special_groups: + chair_emails = get_group_role_emails(group, ["chair", "delegate"]) + if chair_emails: + yield group.acronym + "-chairs", ["ietf"], list(chair_emails) diff --git a/ietf/idindex/tasks.py b/ietf/idindex/tasks.py new file mode 100644 index 0000000000..c01d50cf5d --- /dev/null +++ b/ietf/idindex/tasks.py @@ -0,0 +1,85 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# +# Celery task definitions +# +import shutil + +import debug # pyflakes:ignore + +from celery import shared_task +from contextlib import AbstractContextManager +from pathlib import Path +from tempfile import NamedTemporaryFile + +from .index import all_id_txt, all_id2_txt, id_index_txt + + +class TempFileManager(AbstractContextManager): + def __init__(self, tmpdir=None) -> None: + self.cleanup_list: set[Path] = set() + self.dir = tmpdir + + def make_temp_file(self, content): + with NamedTemporaryFile(mode="wt", delete=False, dir=self.dir) as tf: + tf_path = Path(tf.name) + self.cleanup_list.add(tf_path) + tf.write(content) + return tf_path + + def move_into_place(self, src_path: Path, dest_path: Path): + shutil.move(src_path, dest_path) + dest_path.chmod(0o644) + self.cleanup_list.remove(src_path) + + def cleanup(self): + for tf_path in self.cleanup_list: + tf_path.unlink(missing_ok=True) + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() + return False # False: do not suppress the exception + + +@shared_task +def idindex_update_task(): + """Update I-D indexes""" + id_path = Path("/a/ietfdata/doc/draft/repository") + derived_path = Path("/a/ietfdata/derived") + download_path = Path("/a/www/www6s/download") + + with TempFileManager("/a/tmp") as tmp_mgr: + # Generate copies of new contents + all_id_content = all_id_txt() + all_id_tmpfile = tmp_mgr.make_temp_file(all_id_content) + derived_all_id_tmpfile = tmp_mgr.make_temp_file(all_id_content) + download_all_id_tmpfile = tmp_mgr.make_temp_file(all_id_content) + + id_index_content = id_index_txt() + id_index_tmpfile = tmp_mgr.make_temp_file(id_index_content) + derived_id_index_tmpfile = tmp_mgr.make_temp_file(id_index_content) + download_id_index_tmpfile = tmp_mgr.make_temp_file(id_index_content) + + id_abstracts_content = id_index_txt(with_abstracts=True) + id_abstracts_tmpfile = tmp_mgr.make_temp_file(id_abstracts_content) + derived_id_abstracts_tmpfile = tmp_mgr.make_temp_file(id_abstracts_content) + download_id_abstracts_tmpfile = tmp_mgr.make_temp_file(id_abstracts_content) + + all_id2_content = all_id2_txt() + all_id2_tmpfile = tmp_mgr.make_temp_file(all_id2_content) + derived_all_id2_tmpfile = tmp_mgr.make_temp_file(all_id2_content) + + # Move temp files as-atomically-as-possible into place + tmp_mgr.move_into_place(all_id_tmpfile, id_path / "all_id.txt") + tmp_mgr.move_into_place(derived_all_id_tmpfile, derived_path / "all_id.txt") + tmp_mgr.move_into_place(download_all_id_tmpfile, download_path / "id-all.txt") + + tmp_mgr.move_into_place(id_index_tmpfile, id_path / "1id-index.txt") + tmp_mgr.move_into_place(derived_id_index_tmpfile, derived_path / "1id-index.txt") + tmp_mgr.move_into_place(download_id_index_tmpfile, download_path / "id-index.txt") + + tmp_mgr.move_into_place(id_abstracts_tmpfile, id_path / "1id-abstracts.txt") + tmp_mgr.move_into_place(derived_id_abstracts_tmpfile, derived_path / "1id-abstracts.txt") + tmp_mgr.move_into_place(download_id_abstracts_tmpfile, download_path / "id-abstract.txt") + + tmp_mgr.move_into_place(all_id2_tmpfile, id_path / "all_id2.txt") + tmp_mgr.move_into_place(derived_all_id2_tmpfile, derived_path / "all_id2.txt") diff --git a/ietf/idindex/tests.py b/ietf/idindex/tests.py index c558783789..31c3aaafbf 100644 --- a/ietf/idindex/tests.py +++ b/ietf/idindex/tests.py @@ -3,8 +3,10 @@ import datetime +import mock from pathlib import Path +from tempfile import TemporaryDirectory from django.conf import settings from django.utils import timezone @@ -16,6 +18,7 @@ from ietf.group.factories import GroupFactory from ietf.name.models import DocRelationshipName from ietf.idindex.index import all_id_txt, all_id2_txt, id_index_txt +from ietf.idindex.tasks import idindex_update_task, TempFileManager from ietf.person.factories import PersonFactory, EmailFactory from ietf.utils.test_utils import TestCase @@ -151,3 +154,51 @@ def test_id_index_txt(self): txt = id_index_txt(with_abstracts=True) self.assertTrue(draft.abstract[:20] in txt) + + +class TaskTests(TestCase): + @mock.patch("ietf.idindex.tasks.all_id_txt") + @mock.patch("ietf.idindex.tasks.all_id2_txt") + @mock.patch("ietf.idindex.tasks.id_index_txt") + @mock.patch.object(TempFileManager, "__enter__") + def test_idindex_update_task( + self, + temp_file_mgr_enter_mock, + id_index_mock, + all_id2_mock, + all_id_mock, + ): + # Replace TempFileManager's __enter__() method with one that returns a mock. + # Pass a spec to the mock so we validate that only actual methods are called. + mgr_mock = mock.Mock(spec=TempFileManager) + temp_file_mgr_enter_mock.return_value = mgr_mock + + idindex_update_task() + + self.assertEqual(all_id_mock.call_count, 1) + self.assertEqual(all_id2_mock.call_count, 1) + self.assertEqual(id_index_mock.call_count, 2) + self.assertEqual(id_index_mock.call_args_list[0], (tuple(), dict())) + self.assertEqual( + id_index_mock.call_args_list[1], + (tuple(), {"with_abstracts": True}), + ) + self.assertEqual(mgr_mock.make_temp_file.call_count, 11) + self.assertEqual(mgr_mock.move_into_place.call_count, 11) + + def test_temp_file_manager(self): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + with TempFileManager(temp_path) as tfm: + path1 = tfm.make_temp_file("yay") + path2 = tfm.make_temp_file("boo") # do not keep this one + self.assertTrue(path1.exists()) + self.assertTrue(path2.exists()) + dest = temp_path / "yay.txt" + tfm.move_into_place(path1, dest) + # make sure things were cleaned up... + self.assertFalse(path1.exists()) # moved to dest + self.assertFalse(path2.exists()) # left behind + # check destination contents and permissions + self.assertEqual(dest.read_text(), "yay") + self.assertEqual(dest.stat().st_mode & 0o777, 0o644) diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index ec085ed813..29afa56d78 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -37,7 +37,6 @@ from ietf.group.models import Group, Role, RoleName from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.ietfauth.utils import has_role -from ietf.mailinglists.models import Subscribed from ietf.meeting.factories import MeetingFactory from ietf.nomcom.factories import NomComFactory from ietf.person.factories import PersonFactory, EmailFactory, UserFactory, PersonalApiKeyFactory @@ -227,41 +226,8 @@ def register_and_verify(self, email): self.assertTrue(self.username_in_htpasswd_file(email)) - def test_create_allowlisted_account(self): - email = "new-account@example.com" - - # add allowlist entry - r = self.client.post(urlreverse(ietf.ietfauth.views.login), {"username":"secretary", "password":"secretary+password"}) - self.assertEqual(r.status_code, 302) - self.assertEqual(urlsplit(r["Location"])[2], urlreverse(ietf.ietfauth.views.profile)) - - r = self.client.get(urlreverse(ietf.ietfauth.views.add_account_allowlist)) - self.assertEqual(r.status_code, 200) - self.assertContains(r, "Add an allowlist entry") - - r = self.client.post(urlreverse(ietf.ietfauth.views.add_account_allowlist), {"email": email}) - self.assertEqual(r.status_code, 200) - self.assertContains(r, "Allowlist entry creation successful") - - # log out - r = self.client.post(urlreverse('django.contrib.auth.views.logout'), {}) - self.assertEqual(r.status_code, 200) - - # register and verify allowlisted email - self.register_and_verify(email) - - - def test_create_subscribed_account(self): - # verify creation with email in subscribed list - saved_delay = settings.LIST_ACCOUNT_DELAY - settings.LIST_ACCOUNT_DELAY = 1 - email = "subscribed@example.com" - s = Subscribed(email=email) - s.save() - time.sleep(1.1) - self.register_and_verify(email) - settings.LIST_ACCOUNT_DELAY = saved_delay - + + # This also tests new account creation. def test_create_existing_account(self): # create account once email = "new-account@example.com" diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index 56daae0535..30e639ad65 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -24,5 +24,4 @@ url(r'^review/$', views.review_overview), url(r'^testemail/$', views.test_email), url(r'^username/$', views.change_username), - url(r'^allowlist/add/?$', views.add_account_allowlist), ] diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index ac47634499..8c61b8356a 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -63,11 +63,10 @@ from ietf.group.models import Role, Group from ietf.ietfauth.forms import ( RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, - AllowlistForm, ChangePasswordForm, get_person_form, RoleEmailForm, + ChangePasswordForm, get_person_form, RoleEmailForm, NewEmailForm, ChangeUsernameForm, PersonPasswordForm) from ietf.ietfauth.htpasswd import update_htpasswd_file -from ietf.ietfauth.utils import role_required, has_role -from ietf.mailinglists.models import Allowlisted +from ietf.ietfauth.utils import has_role from ietf.name.models import ExtResourceName from ietf.nomcom.models import NomCom from ietf.person.models import Person, Email, Alias, PersonalApiKey, PERSON_API_KEY_VALUES @@ -160,18 +159,8 @@ def create_account(request): ) new_account_email = None # Indicate to the template that we failed to create the requested account else: - # For the IETF 113 Registration period (at least) we are lowering the - # barriers for account creation to the simple email round-trip check send_account_creation_email(request, new_account_email) - # The following is what to revert to should that lowered barrier prove problematic - # existing = Subscribed.objects.filter(email__iexact=new_account_email).first() - # ok_to_create = ( Allowlisted.objects.filter(email__iexact=new_account_email).exists() - # or existing and (existing.time + TimeDelta(seconds=settings.LIST_ACCOUNT_DELAY)) < DateTime.now() ) - # if ok_to_create: - # send_account_creation_email(request, new_account_email) - # else: - # return render(request, 'registration/manual.html', { 'account_request_email': settings.ACCOUNT_REQUEST_EMAIL }) else: form = RegistrationForm() @@ -610,23 +599,7 @@ def test_email(request): return r -@role_required('Secretariat') -def add_account_allowlist(request): - success = False - if request.method == 'POST': - form = AllowlistForm(request.POST) - if form.is_valid(): - email = form.cleaned_data['email'] - entry = Allowlisted(email=email, by=request.user.person) - entry.save() - success = True - else: - form = AllowlistForm() - return render(request, 'ietfauth/allowlist_form.html', { - 'form': form, - 'success': success, - }) class AddReviewWishForm(forms.Form): doc = SearchableDocumentField(label="Document", doc_type="draft") diff --git a/ietf/mailinglists/admin.py b/ietf/mailinglists/admin.py index 90efaf9c93..51b906053f 100644 --- a/ietf/mailinglists/admin.py +++ b/ietf/mailinglists/admin.py @@ -2,20 +2,15 @@ from django.contrib import admin -from ietf.mailinglists.models import List, Subscribed, Allowlisted +from ietf.mailinglists.models import NonWgMailingList, Allowlisted -class ListAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'description', 'advertised') - search_fields = ('name',) -admin.site.register(List, ListAdmin) -class SubscribedAdmin(admin.ModelAdmin): - list_display = ('id', 'time', 'email') - raw_id_fields = ('lists',) - search_fields = ('email',) -admin.site.register(Subscribed, SubscribedAdmin) +class NonWgMailingListAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'description') + search_fields = ('name',) +admin.site.register(NonWgMailingList, NonWgMailingListAdmin) class AllowlistedAdmin(admin.ModelAdmin): diff --git a/ietf/mailinglists/factories.py b/ietf/mailinglists/factories.py index bc6b2b8203..1a3b0ffa1f 100644 --- a/ietf/mailinglists/factories.py +++ b/ietf/mailinglists/factories.py @@ -3,16 +3,14 @@ import factory -import random -from ietf.mailinglists.models import List +from ietf.mailinglists.models import NonWgMailingList -class ListFactory(factory.django.DjangoModelFactory): +class NonWgMailingListFactory(factory.django.DjangoModelFactory): class Meta: - model = List + model = NonWgMailingList name = factory.Sequence(lambda n: "list-name-%s" % n) description = factory.Faker('sentence', nb_words=10) - advertised = factory.LazyAttribute(lambda obj: random.randint(0, 1)) diff --git a/ietf/mailinglists/management/commands/import_mailman_listinfo.py b/ietf/mailinglists/management/commands/import_mailman_listinfo.py deleted file mode 100644 index 8d23964112..0000000000 --- a/ietf/mailinglists/management/commands/import_mailman_listinfo.py +++ /dev/null @@ -1,130 +0,0 @@ -# Copyright The IETF Trust 2016-2019, All Rights Reserved - -import json -import sys -import subprocess -import time -from textwrap import dedent - -import debug # pyflakes:ignore - -from pathlib import Path - -from django.conf import settings -from django.core.management.base import BaseCommand -from django.core.exceptions import MultipleObjectsReturned - - -from ietf.mailinglists.models import List, Subscribed -from ietf.utils.log import log - -mark = time.time() - -def import_mailman_listinfo(verbosity=0): - def note(msg): - if verbosity > 2: - sys.stdout.write(msg) - sys.stdout.write('\n') - def log_time(msg): - global mark - if verbosity > 1: - t = time.time() - log(msg+' (%.1fs)'% (t-mark)) - mark = t - - cmd = str(Path(settings.BASE_DIR) / "bin" / "mailman_listinfo.py") - result = subprocess.run([cmd], capture_output=True) - if result.stderr: - log("Error exporting information from mailmain") - log(result.stderr) - return - mailman_export = json.loads(result.stdout) - - names = sorted(mailman_export.keys()) - addr_max_length = Subscribed._meta.get_field('email').max_length - - subscribed = { l.name: set(l.subscribed_set.values_list('email', flat=True)) for l in List.objects.all().prefetch_related('subscribed_set') } - - for name in names: - note("List: %s" % mailman_export[name]['internal_name']) - - lists = List.objects.filter(name=mailman_export[name]['real_name']) - if lists.count() > 1: - # Arbitrary choice; we'll update the remaining item next - for item in lists[1:]: - item.delete() - mmlist, created = List.objects.get_or_create(name=mailman_export[name]['real_name']) - dirty = False - desc = mailman_export[name]['description'][:256] - if mmlist.description != desc: - mmlist.description = desc - dirty = True - if mmlist.advertised != mailman_export[name]['advertised']: - mmlist.advertised = mailman_export[name]['advertised'] - dirty = True - if dirty: - mmlist.save() - # The following calls return lowercased addresses - if mailman_export[name]['advertised']: - members = set(mailman_export[name]['members']) - if not mailman_export[name]['real_name'] in subscribed: - # 2022-7-29: lots of these going into the logs but being ignored... - # log("Note: didn't find '%s' in the dictionary of subscriptions" % mailman_export[name]['real_name']) - continue - known = subscribed[mailman_export[name]['real_name']] - log_time(" Fetched known list members from database") - to_remove = known - members - to_add = members - known - for addr in to_remove: - note(" Removing subscription: %s" % (addr)) - old = Subscribed.objects.get(email=addr) # Intentionally leaving this as case-sensitive in postgres - old.lists.remove(mmlist) - if old.lists.count() == 0: - note(" Removing address with no subscriptions: %s" % (addr)) - old.delete() - if to_remove: - log(" Removed %s addresses from %s" % (len(to_remove), name)) - for addr in to_add: - if len(addr) > addr_max_length: - sys.stderr.write(" ** Email address subscribed to '%s' too long for table: <%s>\n" % (name, addr)) - continue - note(" Adding subscription: %s" % (addr)) - try: - new, created = Subscribed.objects.get_or_create(email=addr) # Intentionally leaving this as case-sensitive in postgres - except MultipleObjectsReturned as e: - sys.stderr.write(" ** Error handling %s in %s: %s\n" % (addr, name, e)) - continue - new.lists.add(mmlist) - if to_add: - log(" Added %s addresses to %s" % (len(to_add), name)) - log("Completed import of list info from Mailman") - -class Command(BaseCommand): - """ - Import list information from Mailman. - - Import announced list names, descriptions, and subscribers, by calling the - appropriate Mailman functions and adding entries to the database. - - Run this from cron regularly, with sufficient permissions to access the - mailman database files. - - """ - - help = dedent(__doc__).strip() - - #option_list = BaseCommand.option_list + ( ) - - - def handle(self, *filenames, **options): - """ - - * Import announced lists, with appropriate meta-information. - - * For each list, import the members. - - """ - - verbosity = int(options.get('verbosity')) - - import_mailman_listinfo(verbosity) diff --git a/ietf/mailinglists/migrations/0002_nonwgmailinglist.py b/ietf/mailinglists/migrations/0002_nonwgmailinglist.py new file mode 100644 index 0000000000..dfc941db90 --- /dev/null +++ b/ietf/mailinglists/migrations/0002_nonwgmailinglist.py @@ -0,0 +1,628 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +from django.db import migrations, models + + +def forward(apps, schema_editor): + NonWgMailingList = apps.get_model("mailinglists", "NonWgMailingList") + List = apps.get_model("mailinglists", "List") + + for l in List.objects.filter( + pk__in=[ + 10754, + 10769, + 10770, + 10768, + 10787, + 10785, + 10791, + 10786, + 10816, + 10817, + 10819, + 10818, + 10922, + 10923, + 10921, + 10940, + 10941, + 10942, + 572, + 10297, + 182, + 43, + 10704, + 10314, + 201, + 419, + 282, + 149, + 223, + 10874, + 10598, + 10639, + 10875, + 10737, + 105, + 65, + 10781, + 10771, + 10946, + 518, + 421, + 214, + 285, + 393, + 445, + 553, + 183, + 10725, + 33, + 10766, + 114, + 417, + 10789, + 10876, + 4244, + 10705, + 10706, + 10878, + 10324, + 10879, + 10642, + 10821, + 547, + 532, + 10636, + 10592, + 327, + 248, + 10697, + 288, + 346, + 10731, + 10955, + 10857, + 446, + 55, + 10799, + 10800, + 10801, + 10612, + 73, + 3, + 358, + 9640, + 10868, + 378, + 462, + 6595, + 10914, + 10915, + 197, + 63, + 558, + 10824, + 124, + 10881, + 177, + 312, + 252, + 185, + 523, + 4572, + 10618, + 206, + 68, + 10859, + 560, + 513, + 246, + 7817, + 148, + 10864, + 10589, + 10773, + 10748, + 364, + 311, + 10302, + 10272, + 10929, + 171, + 10865, + 10919, + 377, + 469, + 467, + 411, + 505, + 6318, + 10811, + 10304, + 10882, + 10845, + 568, + 10883, + 4774, + 264, + 10779, + 10884, + 10303, + 409, + 10590, + 451, + 10749, + 10765, + 486, + 519, + 10593, + 10313, + 550, + 10707, + 307, + 10861, + 10654, + 10708, + 10275, + 134, + 460, + 10911, + 10574, + 10885, + 10814, + 10676, + 10747, + 10305, + 10688, + 36, + 10844, + 10620, + 458, + 10282, + 10594, + 10752, + 389, + 296, + 10684, + 48, + 533, + 443, + 10739, + 491, + 139, + 461, + 10690, + 424, + 290, + 336, + 31, + 10709, + 382, + 10866, + 10724, + 539, + 10710, + 559, + 10609, + 74, + 10582, + 133, + 10621, + 34, + 10596, + 442, + 13, + 56, + 128, + 323, + 10285, + 80, + 315, + 3520, + 10949, + 10950, + 189, + 2599, + 10822, + 164, + 10267, + 10286, + 464, + 440, + 254, + 262, + 10943, + 465, + 75, + 179, + 162, + 457, + 10572, + 372, + 452, + 10273, + 88, + 366, + 331, + 140, + 407, + 416, + 91, + 10632, + 542, + 151, + 117, + 431, + 10628, + 10271, + 14, + 540, + 278, + 352, + 159, + 10851, + 9981, + 10694, + 10619, + 10732, + 320, + 348, + 338, + 349, + 10678, + 468, + 293, + 350, + 402, + 57, + 524, + 141, + 71, + 67, + 508, + 7828, + 10268, + 10631, + 10713, + 10889, + 345, + 78, + 342, + 190, + 10869, + 46, + 334, + 255, + 5823, + 400, + 10867, + 23, + 10666, + 10685, + 405, + 2801, + 92, + 137, + 10640, + 10656, + 104, + 123, + 10643, + 10891, + 466, + 10567, + 10318, + 526, + 30, + 222, + 194, + 10735, + 10714, + 247, + 493, + 1162, + 414, + 10648, + 10677, + 126, + 16, + 422, + 271, + 295, + 81, + 10634, + 544, + 10850, + 426, + 573, + 353, + 10829, + 538, + 10913, + 10566, + 167, + 10675, + 272, + 10673, + 10767, + 528, + 284, + 564, + 268, + 10825, + 231, + 520, + 10645, + 10872, + 515, + 10956, + 10947, + 569, + 233, + 10952, + 195, + 10938, + 2809, + 10591, + 10665, + 9639, + 10775, + 10760, + 10715, + 10716, + 10667, + 361, + 184, + 10935, + 10957, + 10944, + 94, + 449, + 525, + 1962, + 10300, + 10894, + 9156, + 10774, + 256, + 289, + 218, + 187, + 40, + 10777, + 10761, + 10670, + 249, + 10764, + 420, + 548, + 232, + 410, + 196, + 72, + 335, + 70, + 146, + 10287, + 10299, + 10311, + 10895, + 10617, + 531, + 343, + 10934, + 10933, + 10597, + 158, + 10600, + 10692, + 8630, + 556, + 324, + 11, + 10784, + 498, + 10772, + 478, + 10833, + 10691, + 391, + 10565, + 10669, + 113, + 110, + 7831, + 10855, + 10312, + 10315, + 10896, + 10672, + 10306, + 438, + 395, + 82, + 10599, + 10953, + 10858, + 10807, + 10717, + 310, + 10808, + 119, + 10595, + 10718, + 10317, + 10898, + 454, + 427, + 10583, + 10916, + 403, + 10843, + 10899, + 291, + 10812, + 10900, + 10794, + 341, + 121, + 230, + 136, + 166, + 394, + 234, + 10901, + 2466, + 10573, + 10939, + 221, + 490, + 10820, + 10873, + 10792, + 10870, + 10793, + 10904, + 181, + 10693, + 482, + 10611, + 125, + 10568, + 10788, + 211, + 10756, + 10719, + 100, + 228, + 5833, + 251, + 122, + 39, + 534, + 437, + 504, + 10613, + 439, + 306, + 10863, + 10823, + 10926, + 76, + 227, + 59, + 42, + 455, + 10927, + 10928, + 204, + 430, + 10720, + 267, + 396, + 10849, + 10308, + 281, + 10905, + 10736, + 168, + 153, + 385, + 89, + 529, + 412, + 215, + 484, + 10951, + 66, + 173, + 10633, + 10681, + 3613, + 10274, + 10750, + 367, + 387, + 10832, + 35, + 147, + 10325, + 10671, + 565, + 313, + 10871, + 10751, + 37, + 10936, + 10937, + 287, + 496, + 244, + 10841, + 10683, + 10906, + 10584, + 479, + 10856, + 163, + 10910, + 257, + 276, + 10840, + 10689, + 365, + 10847, + 99, + 77, + 435, + 213, + 15, + 10932, + 58, + 10722, + 131, + 363, + 10674, + 322, + 180, + 10917, + 10918, + 10738, + 10954, + 10581, + 208, + 337, + 4, + 571, + 10668, + 10291, + ] + ): + NonWgMailingList.objects.create(name=l.name, description=l.description) + +class Migration(migrations.Migration): + + dependencies = [ + ("mailinglists", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="NonWgMailingList", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=32)), + ("description", models.CharField(max_length=256)), + ], + ), + migrations.RunPython(forward), + ] diff --git a/ietf/mailinglists/migrations/0003_remove_subscribed_lists_delete_list_and_more.py b/ietf/mailinglists/migrations/0003_remove_subscribed_lists_delete_list_and_more.py new file mode 100644 index 0000000000..6171136b2a --- /dev/null +++ b/ietf/mailinglists/migrations/0003_remove_subscribed_lists_delete_list_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.9 on 2024-02-02 23:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("mailinglists", "0002_nonwgmailinglist"), + ] + + operations = [ + migrations.RemoveField( + model_name="subscribed", + name="lists", + ), + migrations.DeleteModel( + name="List", + ), + migrations.DeleteModel( + name="Subscribed", + ), + ] diff --git a/ietf/mailinglists/models.py b/ietf/mailinglists/models.py index 21f3a76710..f575ffe5a4 100644 --- a/ietf/mailinglists/models.py +++ b/ietf/mailinglists/models.py @@ -9,25 +9,20 @@ from ietf.person.models import Person from ietf.utils.models import ForeignKey -class List(models.Model): + +# NonWgMailingList is a temporary bridging class to hold information known about mailman2 +# while decoupling from mailman2 until we integrate with mailman3 +class NonWgMailingList(models.Model): name = models.CharField(max_length=32) description = models.CharField(max_length=256) - advertised = models.BooleanField(default=True) def __str__(self): - return "" % self.name + return "" % self.name def info_url(self): return settings.MAILING_LIST_INFO_URL % {'list_addr': self.name } -class Subscribed(models.Model): - time = models.DateTimeField(auto_now_add=True) - email = models.CharField(max_length=128, validators=[validate_email]) - lists = models.ManyToManyField(List) - def __str__(self): - return "" % (self.email, self.time) - class Meta: - verbose_name_plural = "Subscribed" - +# Allowlisted is unused, but is not being dropped until its human-curated content +# is archived outside this database. class Allowlisted(models.Model): time = models.DateTimeField(auto_now_add=True) email = models.CharField("Email address", max_length=64, validators=[validate_email]) diff --git a/ietf/mailinglists/resources.py b/ietf/mailinglists/resources.py index 018a8327b1..b075d18079 100644 --- a/ietf/mailinglists/resources.py +++ b/ietf/mailinglists/resources.py @@ -11,7 +11,7 @@ from ietf import api from ietf.api import ToOneField # pyflakes:ignore -from ietf.mailinglists.models import Allowlisted, List, Subscribed +from ietf.mailinglists.models import Allowlisted, NonWgMailingList from ietf.person.resources import PersonResource @@ -31,34 +31,19 @@ class Meta: } api.mailinglists.register(AllowlistedResource()) -class ListResource(ModelResource): +class NonWgMailingListResource(ModelResource): class Meta: - queryset = List.objects.all() + queryset = NonWgMailingList.objects.all() serializer = api.Serializer() cache = SimpleCache() - #resource_name = 'list' + #resource_name = 'nonwgmailinglist' ordering = ['id', ] filtering = { "id": ALL, "name": ALL, "description": ALL, - "advertised": ALL, } -api.mailinglists.register(ListResource()) +api.mailinglists.register(NonWgMailingListResource()) + -class SubscribedResource(ModelResource): - lists = ToManyField(ListResource, 'lists', null=True) - class Meta: - queryset = Subscribed.objects.all() - serializer = api.Serializer() - cache = SimpleCache() - #resource_name = 'subscribed' - ordering = ['id', ] - filtering = { - "id": ALL, - "time": ALL, - "email": ALL, - "lists": ALL_WITH_RELATIONS, - } -api.mailinglists.register(SubscribedResource()) diff --git a/ietf/mailinglists/tests.py b/ietf/mailinglists/tests.py index 0c983da80c..0b44d28c71 100644 --- a/ietf/mailinglists/tests.py +++ b/ietf/mailinglists/tests.py @@ -9,7 +9,7 @@ import debug # pyflakes:ignore from ietf.group.factories import GroupFactory -from ietf.mailinglists.factories import ListFactory +from ietf.mailinglists.factories import NonWgMailingListFactory from ietf.utils.test_utils import TestCase @@ -32,23 +32,13 @@ def test_groups(self): def test_nonwg(self): - groups = list() - groups.append(GroupFactory(type_id='wg', acronym='mars', list_archive='https://ietf.org/mars')) - groups.append(GroupFactory(type_id='wg', acronym='ames', state_id='conclude', list_archive='https://ietf.org/ames')) - groups.append(GroupFactory(type_id='wg', acronym='newstuff', state_id='bof', list_archive='https://ietf.org/newstuff')) - groups.append(GroupFactory(type_id='rg', acronym='research', list_archive='https://irtf.org/research')) - lists = ListFactory.create_batch(7) + + lists = NonWgMailingListFactory.create_batch(7) url = urlreverse("ietf.mailinglists.views.nonwg") r = self.client.get(url) for l in lists: - if l.advertised: self.assertContains(r, l.name) self.assertContains(r, l.description) - else: - self.assertNotContains(r, l.name, html=True) - self.assertNotContains(r, l.description, html=True) - for g in groups: - self.assertNotContains(r, g.acronym, html=True) diff --git a/ietf/mailinglists/views.py b/ietf/mailinglists/views.py index 51c31c546f..460f30e164 100644 --- a/ietf/mailinglists/views.py +++ b/ietf/mailinglists/views.py @@ -1,33 +1,25 @@ # Copyright The IETF Trust 2007-2022, All Rights Reserved -import re - from django.shortcuts import render -import debug # pyflakes:ignore +import debug # pyflakes:ignore from ietf.group.models import Group -from ietf.mailinglists.models import List +from ietf.mailinglists.models import NonWgMailingList + def groups(request): - groups = Group.objects.filter(type__features__acts_like_wg=True, list_archive__startswith='http').exclude(state__in=('bof', 'conclude')).order_by("acronym") + groups = ( + Group.objects.filter( + type__features__acts_like_wg=True, list_archive__startswith="http" + ) + .exclude(state__in=("bof", "conclude")) + .order_by("acronym") + ) + + return render(request, "mailinglists/group_archives.html", {"groups": groups}) - return render(request, "mailinglists/group_archives.html", { "groups": groups } ) def nonwg(request): - groups = Group.objects.filter(type__features__acts_like_wg=True).exclude(state__in=['bof']).order_by("acronym") - - #urls = [ g.list_archive for g in groups if '.ietf.org' in g.list_archive ] - - wg_lists = set() - for g in groups: - wg_lists.add(g.acronym) - match = re.search(r'^(https?://mailarchive.ietf.org/arch/(browse/|search/\?email-list=))(?P[^/]*)/?$', g.list_archive) - if match: - wg_lists.add(match.group('name').lower()) - - lists = List.objects.filter(advertised=True) - #debug.show('lists.count()') - lists = lists.exclude(name__in=wg_lists).order_by('name') - #debug.show('lists.count()') - return render(request, "mailinglists/nonwg.html", { "lists": lists } ) + lists = NonWgMailingList.objects.order_by("name") + return render(request, "mailinglists/nonwg.html", {"lists": lists}) diff --git a/ietf/secr/templates/includes/sessions_request_form.html b/ietf/secr/templates/includes/sessions_request_form.html index cd36fe45f1..61b1673357 100755 --- a/ietf/secr/templates/includes/sessions_request_form.html +++ b/ietf/secr/templates/includes/sessions_request_form.html @@ -103,16 +103,7 @@ Joint session with:
(To request one session for multiple WGs together.) - {{ form.joint_with_groups_selector }} -
- {{ form.joint_with_groups.errors }}{{ form.joint_with_groups }} - - - - - Of the sessions requested by this WG, the joint session, if applicable, is: - - {{ form.joint_for_session.errors }}{{ form.joint_for_session }} + To request a joint session with another group, please contact the secretariat. {% endif %} diff --git a/ietf/settings.py b/ietf/settings.py index e3f7e7f5de..17ed58d6f8 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -169,8 +169,8 @@ STATIC_URL = "/static/" STATIC_ROOT = os.path.abspath(BASE_DIR + "/../static/") else: - STATIC_URL = "https://static.ietf.org/lib/%s/"%__version__ - # Intentionally not setting STATIC_ROOT - see django/django (the default is None) + STATIC_URL = "https://static.ietf.org/dt/%s/"%__version__ + STATIC_ROOT = "/a/www/www6s/lib/dt/%s/"%__version__ # List of finder classes that know how to find static files in # various locations. diff --git a/ietf/sync/tasks.py b/ietf/sync/tasks.py index 1e4cfe0772..bc1218601f 100644 --- a/ietf/sync/tasks.py +++ b/ietf/sync/tasks.py @@ -5,11 +5,14 @@ import datetime import io import requests + from celery import shared_task from django.conf import settings +from django.utils import timezone -from ietf.sync.rfceditor import MIN_ERRATA_RESULTS, MIN_INDEX_RESULTS, parse_index, update_docs_from_rfc_index +from ietf.sync import iana +from ietf.sync import rfceditor from ietf.utils import log from ietf.utils.timezone import date_today @@ -44,7 +47,7 @@ def rfc_editor_index_update_task(full_index=False): log.log(f'GET request timed out retrieving RFC editor index: {exc}') return # failed rfc_index_xml = response.text - index_data = parse_index(io.StringIO(rfc_index_xml)) + index_data = rfceditor.parse_index(io.StringIO(rfc_index_xml)) try: response = requests.get( settings.RFC_EDITOR_ERRATA_JSON_URL, @@ -54,14 +57,98 @@ def rfc_editor_index_update_task(full_index=False): log.log(f'GET request timed out retrieving RFC editor errata: {exc}') return # failed errata_data = response.json() - if len(index_data) < MIN_INDEX_RESULTS: + if len(index_data) < rfceditor.MIN_INDEX_RESULTS: log.log("Not enough index entries, only %s" % len(index_data)) return # failed - if len(errata_data) < MIN_ERRATA_RESULTS: + if len(errata_data) < rfceditor.MIN_ERRATA_RESULTS: log.log("Not enough errata entries, only %s" % len(errata_data)) return # failed - for rfc_number, changes, doc, rfc_published in update_docs_from_rfc_index( + for rfc_number, changes, doc, rfc_published in rfceditor.update_docs_from_rfc_index( index_data, errata_data, skip_older_than_date=skip_date ): for c in changes: log.log("RFC%s, %s: %s" % (rfc_number, doc.name, c)) + + +@shared_task +def iana_changes_update_task(): + # compensate to avoid we ask for something that happened now and then + # don't get it back because our request interval is slightly off + CLOCK_SKEW_COMPENSATION = 5 # seconds + + # actually the interface accepts 24 hours, but then we get into + # trouble with daylights savings - meh + MAX_INTERVAL_ACCEPTED_BY_IANA = datetime.timedelta(hours=23) + + start = ( + timezone.now() + - datetime.timedelta(hours=23) + + datetime.timedelta(seconds=CLOCK_SKEW_COMPENSATION,) + ) + end = start + datetime.timedelta(hours=23) + + t = start + while t < end: + # the IANA server doesn't allow us to fetch more than a certain + # period, so loop over the requested period and make multiple + # requests if necessary + + text = iana.fetch_changes_json( + settings.IANA_SYNC_CHANGES_URL, t, min(end, t + MAX_INTERVAL_ACCEPTED_BY_IANA) + ) + log.log(f"Retrieved the JSON: {text}") + + changes = iana.parse_changes_json(text) + added_events, warnings = iana.update_history_with_changes( + changes, send_email=True + ) + + for e in added_events: + log.log( + f"Added event for {e.doc_id} {e.time}: {e.desc} (parsed json: {e.json})" + ) + + for w in warnings: + log.log(f"WARNING: {w}") + + t += MAX_INTERVAL_ACCEPTED_BY_IANA + + +@shared_task +def iana_protocols_update_task(): + # Earliest date for which we have data suitable to update (was described as + # "this needs to be the date where this tool is first deployed" in the original + # iana-protocols-updates script)" + rfc_must_published_later_than = datetime.datetime( + 2012, + 11, + 26, + tzinfo=datetime.timezone.utc, + ) + + try: + response = requests.get( + settings.IANA_SYNC_PROTOCOLS_URL, + timeout=30, + ) + except requests.Timeout as exc: + log.log(f'GET request timed out retrieving IANA protocols page: {exc}') + return + + rfc_numbers = iana.parse_protocol_page(response.text) + + def batched(l, n): + """Split list l up in batches of max size n. + + For Python 3.12 or later, replace this with itertools.batched() + """ + return (l[i:i + n] for i in range(0, len(l), n)) + + for batch in batched(rfc_numbers, 100): + updated = iana.update_rfc_log_from_protocol_page( + batch, + rfc_must_published_later_than, + ) + + for d in updated: + log.log("Added history entry for %s" % d.display_name()) diff --git a/ietf/sync/tests.py b/ietf/sync/tests.py index 6ce1d12521..fec353a97c 100644 --- a/ietf/sync/tests.py +++ b/ietf/sync/tests.py @@ -19,7 +19,7 @@ import debug # pyflakes:ignore -from ietf.doc.factories import WgDraftFactory, RfcFactory, DocumentAuthorFactory +from ietf.doc.factories import WgDraftFactory, RfcFactory, DocumentAuthorFactory, DocEventFactory from ietf.doc.models import Document, DocEvent, DeletedEvent, DocTagName, RelatedDocument, State, StateDocEvent from ietf.doc.utils import add_state_change_event from ietf.group.factories import GroupFactory @@ -685,8 +685,8 @@ class TaskTests(TestCase): RFC_EDITOR_INDEX_URL="https://rfc-editor.example.com/index/", RFC_EDITOR_ERRATA_JSON_URL="https://rfc-editor.example.com/errata/", ) - @mock.patch("ietf.sync.tasks.update_docs_from_rfc_index") - @mock.patch("ietf.sync.tasks.parse_index") + @mock.patch("ietf.sync.tasks.rfceditor.update_docs_from_rfc_index") + @mock.patch("ietf.sync.tasks.rfceditor.parse_index") @mock.patch("ietf.sync.tasks.requests.get") def test_rfc_editor_index_update_task( self, requests_get_mock, parse_index_mock, update_docs_mock @@ -804,3 +804,102 @@ def json(self): parse_index_mock.return_value = MockIndexData(length=rfceditor.MIN_INDEX_RESULTS) tasks.rfc_editor_index_update_task(full_index=False) self.assertFalse(update_docs_mock.called) + + @override_settings(IANA_SYNC_CHANGES_URL="https://iana.example.com/sync/") + @mock.patch("ietf.sync.tasks.iana.update_history_with_changes") + @mock.patch("ietf.sync.tasks.iana.parse_changes_json") + @mock.patch("ietf.sync.tasks.iana.fetch_changes_json") + def test_iana_changes_update_task( + self, + fetch_changes_mock, + parse_changes_mock, + update_history_mock, + ): + # set up mocks + fetch_return_val = object() + fetch_changes_mock.return_value = fetch_return_val + parse_return_val = object() + parse_changes_mock.return_value = parse_return_val + event_with_json = DocEventFactory() + event_with_json.json = "hi I'm json" + update_history_mock.return_value = [ + [event_with_json], # events + ["oh no!"], # warnings + ] + + tasks.iana_changes_update_task() + self.assertEqual(fetch_changes_mock.call_count, 1) + self.assertEqual( + fetch_changes_mock.call_args[0][0], + "https://iana.example.com/sync/", + ) + self.assertTrue(parse_changes_mock.called) + self.assertEqual( + parse_changes_mock.call_args, + ((fetch_return_val,), {}), + ) + self.assertTrue(update_history_mock.called) + self.assertEqual( + update_history_mock.call_args, + ((parse_return_val,), {"send_email": True}), + ) + + @override_settings(IANA_SYNC_PROTOCOLS_URL="https://iana.example.com/proto/") + @mock.patch("ietf.sync.tasks.iana.update_rfc_log_from_protocol_page") + @mock.patch("ietf.sync.tasks.iana.parse_protocol_page") + @mock.patch("ietf.sync.tasks.requests.get") + def test_iana_protocols_update_task( + self, + requests_get_mock, + parse_protocols_mock, + update_rfc_log_mock, + ): + # set up mocks + requests_get_mock.return_value = mock.Mock(text="fetched response") + parse_protocols_mock.return_value = range(110) # larger than batch size of 100 + update_rfc_log_mock.return_value = [ + mock.Mock(display_name=mock.Mock(return_value="name")) + ] + + # call the task + tasks.iana_protocols_update_task() + + # check that it did the right things + self.assertTrue(requests_get_mock.called) + self.assertEqual( + requests_get_mock.call_args[0], + ("https://iana.example.com/proto/",), + ) + self.assertTrue(parse_protocols_mock.called) + self.assertEqual( + parse_protocols_mock.call_args[0], + ("fetched response",), + ) + self.assertEqual(update_rfc_log_mock.call_count, 2) + self.assertEqual( + update_rfc_log_mock.call_args_list[0][0][0], + range(100), # first batch + ) + self.assertEqual( + update_rfc_log_mock.call_args_list[1][0][0], + range(100, 110), # second batch + ) + # make sure the calls use the same later_than date and that it's the expected one + published_later_than = set( + update_rfc_log_mock.call_args_list[n][0][1] for n in (0, 1) + ) + self.assertEqual( + published_later_than, + {datetime.datetime(2012,11,26,tzinfo=datetime.timezone.utc)} + ) + + # try with an exception + requests_get_mock.reset_mock() + parse_protocols_mock.reset_mock() + update_rfc_log_mock.reset_mock() + requests_get_mock.side_effect = requests.Timeout + + tasks.iana_protocols_update_task() + self.assertTrue(requests_get_mock.called) + self.assertFalse(parse_protocols_mock.called) + self.assertFalse(update_rfc_log_mock.called) diff --git a/ietf/sync/views.py b/ietf/sync/views.py index 431dd0a8fc..30b2a928e5 100644 --- a/ietf/sync/views.py +++ b/ietf/sync/views.py @@ -17,6 +17,7 @@ from ietf.doc.models import DeletedEvent, StateDocEvent, DocEvent from ietf.ietfauth.utils import role_required, has_role +from ietf.sync import tasks from ietf.sync.discrepancies import find_discrepancies from ietf.utils.serialize import object_as_shallow_dict from ietf.utils.log import log @@ -91,19 +92,18 @@ def runscript(name): log("Subprocess error %s when running '%s': %s %s" % (p.returncode, cmd, err, out)) raise subprocess.CalledProcessError(p.returncode, cmdstring, "\n".join([err, out])) - log("Running sync script from notify view POST") - - if notification == "protocols": - runscript("iana-protocols-updates") - - if notification == "changes": - runscript("iana-changes-updates") - - if notification == "queue": - runscript("rfc-editor-queue-updates") - if notification == "index": - runscript("rfc-editor-index-updates") + log("Queuing RFC Editor index sync from notify view POST") + tasks.rfc_editor_index_update_task.delay() + elif notification == "changes": + log("Queuing IANA changes sync from notify view POST") + tasks.iana_changes_update_task.delay() + elif notification == "protocols": + log("Queuing IANA protocols sync from notify view POST") + tasks.iana_protocols_update_task.delay() + elif notification == "queue": + log("Running sync script from notify view POST") + runscript("rfc-editor-queue-updates") return HttpResponse("OK", content_type="text/plain; charset=%s"%settings.DEFAULT_CHARSET) diff --git a/ietf/templates/base/menu_user.html b/ietf/templates/base/menu_user.html index 8245ece71c..9a0bf56838 100644 --- a/ietf/templates/base/menu_user.html +++ b/ietf/templates/base/menu_user.html @@ -186,12 +186,6 @@ Sync discrepancies -
  • - - Account allowlist - -
  • {% endif %} {% if user|has_role:"IANA" %} {% if flavor == "top" %} diff --git a/ietf/templates/ietfauth/allowlist_form.html b/ietf/templates/ietfauth/allowlist_form.html deleted file mode 100644 index c7f3981b7b..0000000000 --- a/ietf/templates/ietfauth/allowlist_form.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2016, All Rights Reserved #} -{% load origin %} -{% load django_bootstrap5 %} -{% block title %}Set up test email address{% endblock %} -{% block content %} - {% origin %} - {% if success %} -

    Allowlist entry creation successful

    -

    - Please ask the requestor to try the - account creation form - again, with the allowlisted email address. -

    - {% else %} -

    Add an allowlist entry for account creation.

    -

    - When an email request comes in for assistance with account creation - because the automated account creation has failed, you can add the - address to an account creation allowlist here. -

    -

    - Before you do so, please complete the following 3 verification steps: -

    -
      -
    1. - Has the person provided relevant information in his request, or has he simply - copied the text from the account creation failure message? All genuine (non-spam) - account creation requests seen between 2009 and 2016 for tools.ietf.org have - contained a reasonable request message, rather than just copy-pasting the account - creation failure message. If there's no proper request message, step 2 below can - be performed to make sure the request is bogus, but if that also fails, no further - effort should be needed. -
    2. -
    3. - Google for the person's name within the ietf.org site: "Jane Doe site:ietf.org". If - found, and the email address matches an address used in Internet-Drafts or discussions, - things are fine, and it's OK to add the address to the allowlist using this form, - and ask the person to please try the - account creation form - again. -
    4. -
    5. -

      - If google finds no trace of the person being an ietf participant, he or she could - still be somebody who is just getting involved in IETF work. A datatracker account - is probably not necessary, (no account is necessary to 'join' a WG -- the right thing - in that case is to join the right mailing list, and the person could be told so) -- - but in case this is a legitimate request, please email the person and ask: - - "Which wgs do you require a password for?" - -

      -

      - This is a bit of a trick question, because it is very unlikely that somebody who - isn't involved in IETF work will give a reasonable response, while almost any answer - from somebody who is doing IETF work will show that they have some clue. -

      -

      - Please note the exact wording. Do not ask about "working groups" -- - that will make it easier for people to google for IETF working groups. Ask the - question as given above, with lowercase "wgs". -

      -

      - If the answer to this question shows clue, then add the address to the allowlist - using this form, and ask the person to please try the - account creation form - again. -

      -
    6. -
    -
    - {% csrf_token %} - {% bootstrap_form form %} - -
    - {% endif %} -{% endblock %} \ No newline at end of file diff --git a/ietf/utils/management/commands/periodic_tasks.py b/ietf/utils/management/commands/periodic_tasks.py index 5dc891cfb4..14a21fe964 100644 --- a/ietf/utils/management/commands/periodic_tasks.py +++ b/ietf/utils/management/commands/periodic_tasks.py @@ -5,32 +5,41 @@ from django.core.management.base import BaseCommand CRONTAB_DEFS = { + # same as "@weekly" in a crontab + "weekly": { + "minute": "0", + "hour": "0", + "day_of_month": "*", + "month_of_year": "*", + "day_of_week": "0", + }, "daily": { "minute": "5", "hour": "0", - "day_of_week": "*", "day_of_month": "*", "month_of_year": "*", + "day_of_week": "*", }, "hourly": { "minute": "5", "hour": "*", - "day_of_week": "*", "day_of_month": "*", "month_of_year": "*", + "day_of_week": "*", }, "every_15m": { "minute": "*/15", "hour": "*", - "day_of_week": "*", "day_of_month": "*", "month_of_year": "*", + "day_of_week": "*", }, } class Command(BaseCommand): """Manage periodic tasks""" + crontabs = None def add_arguments(self, parser): parser.add_argument("--create-default", action="store_true") @@ -112,6 +121,56 @@ def create_default_tasks(self): ), ) + PeriodicTask.objects.get_or_create( + name="Expire I-Ds", + task="ietf.doc.tasks.expire_ids_task", + defaults=dict( + enabled=False, + crontab=self.crontabs["daily"], + description="Create expiration notices for expired I-Ds", + ), + ) + + PeriodicTask.objects.get_or_create( + name="Sync with IANA changes", + task="ietf.sync.tasks.iana_changes_update_task", + defaults=dict( + enabled=False, + crontab=self.crontabs["hourly"], + description="Fetch change list from IANA and apply to documents", + ), + ) + + PeriodicTask.objects.get_or_create( + name="Sync with IANA protocols page", + task="ietf.sync.tasks.iana_changes_update_task", + defaults=dict( + enabled=False, + crontab=self.crontabs["hourly"], + description="Fetch protocols page from IANA and update document event logs", + ), + ) + + PeriodicTask.objects.get_or_create( + name="Update I-D index files", + task="ietf.idindex.tasks.idindex_update_task", + defaults=dict( + enabled=False, + crontab=self.crontabs["hourly"], + description="Update I-D index files", + ), + ) + + PeriodicTask.objects.get_or_create( + name="Send expiration notifications", + task="ietf.doc.tasks.notify_expirations_task", + defaults=dict( + enabled=False, + crontab=self.crontabs["weekly"], + description="Send notifications about I-Ds that will expire in the next 14 days", + ) + ) + def show_tasks(self): for label, crontab in self.crontabs.items(): tasks = PeriodicTask.objects.filter(crontab=crontab).order_by( diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 7123af5c81..c5d3472751 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -84,7 +84,7 @@ def make_immutable_base_data(): create_person(iab, "chair") create_person(iab, "member") - ise = create_group(name="Independent Submission Editor", acronym="ise", type_id="rfcedtyp") + ise = create_group(name="Independent Submission Editor", acronym="ise", type_id="ise") create_person(ise, "chair") rsoc = create_group(name="RFC Series Oversight Committee", acronym="rsoc", type_id="rfcedtyp") diff --git a/package.json b/package.json index 4d6ad2fb87..00d4ef97cd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "bootstrap": "5.3.2", "bootstrap-icons": "1.11.3", "browser-fs-access": "0.35.0", - "caniuse-lite": "1.0.30001576", + "caniuse-lite": "1.0.30001581", "d3": "7.8.5", "file-saver": "2.0.5", "highcharts": "11.3.0", @@ -46,7 +46,7 @@ "slugify": "1.6.6", "sortablejs": "1.15.2", "vanillajs-datepicker": "1.3.4", - "vue": "3.4.13", + "vue": "3.4.15", "vue-router": "4.2.5", "zxcvbn": "4.4.2" }, @@ -70,9 +70,9 @@ "jquery-migrate": "3.4.1", "parcel": "2.11.0", "pug": "3.0.2", - "sass": "1.69.7", + "sass": "1.70.0", "seedrandom": "3.0.5", - "vite": "4.5.1" + "vite": "4.5.2" }, "targets": { "ietf": { diff --git a/playwright/package-lock.json b/playwright/package-lock.json index f7ae6e8ddc..baa6f31c56 100644 --- a/playwright/package-lock.json +++ b/playwright/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "dependencies": { - "@faker-js/faker": "8.3.1", + "@faker-js/faker": "8.4.0", "lodash": "4.17.21", "lodash-es": "4.17.21", "luxon": "3.4.4", @@ -15,14 +15,14 @@ "slugify": "1.6.6" }, "devDependencies": { - "@playwright/test": "1.40.1", + "@playwright/test": "1.41.2", "eslint": "8.56.0", "eslint-config-standard": "17.1.0", "eslint-plugin-import": "2.29.1", "eslint-plugin-n": "16.6.2", "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "6.1.1", - "npm-check-updates": "16.14.12" + "npm-check-updates": "16.14.14" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -101,9 +101,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.3.1.tgz", - "integrity": "sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz", + "integrity": "sha512-htW87352wzUCdX1jyUQocUcmAaFqcR/w082EC8iP/gtkF0K+aKcBp0hR5Arb7dzR8tQ1TrhE9DNa5EbJELm84w==", "funding": [ { "type": "opencollective", @@ -399,12 +399,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.1.tgz", - "integrity": "sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==", + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", + "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", "dev": true, "dependencies": { - "playwright": "1.40.1" + "playwright": "1.41.2" }, "bin": { "playwright": "cli.js" @@ -3804,9 +3804,9 @@ } }, "node_modules/npm-check-updates": { - "version": "16.14.12", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.12.tgz", - "integrity": "sha512-5FvqaDX8AqWWTDQFbBllgLwoRXTvzlqVIRSKl9Kg8bYZTfNwMnrp1Zlmb5e/ocf11UjPTc+ShBFjYQ7kg6FL0w==", + "version": "16.14.14", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.14.tgz", + "integrity": "sha512-Y3ajS/Ep40jM489rLBdz9jehn/BMil5s9fA4PSr2ZJxxSmtLWCSmRqsI2IEZ9Nb3MTMu8a3s7kBs0l+JbjdkTA==", "dev": true, "dependencies": { "chalk": "^5.3.0", @@ -4407,12 +4407,12 @@ } }, "node_modules/playwright": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.1.tgz", - "integrity": "sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==", + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", + "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", "dev": true, "dependencies": { - "playwright-core": "1.40.1" + "playwright-core": "1.41.2" }, "bin": { "playwright": "cli.js" @@ -4425,9 +4425,9 @@ } }, "node_modules/playwright-core": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.1.tgz", - "integrity": "sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==", + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", + "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -6015,9 +6015,9 @@ "dev": true }, "@faker-js/faker": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.3.1.tgz", - "integrity": "sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==" + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz", + "integrity": "sha512-htW87352wzUCdX1jyUQocUcmAaFqcR/w082EC8iP/gtkF0K+aKcBp0hR5Arb7dzR8tQ1TrhE9DNa5EbJELm84w==" }, "@humanwhocodes/config-array": { "version": "0.11.13", @@ -6226,12 +6226,12 @@ "optional": true }, "@playwright/test": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.1.tgz", - "integrity": "sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==", + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", + "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", "dev": true, "requires": { - "playwright": "1.40.1" + "playwright": "1.41.2" } }, "@pnpm/network.ca-file": { @@ -8721,9 +8721,9 @@ } }, "npm-check-updates": { - "version": "16.14.12", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.12.tgz", - "integrity": "sha512-5FvqaDX8AqWWTDQFbBllgLwoRXTvzlqVIRSKl9Kg8bYZTfNwMnrp1Zlmb5e/ocf11UjPTc+ShBFjYQ7kg6FL0w==", + "version": "16.14.14", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.14.tgz", + "integrity": "sha512-Y3ajS/Ep40jM489rLBdz9jehn/BMil5s9fA4PSr2ZJxxSmtLWCSmRqsI2IEZ9Nb3MTMu8a3s7kBs0l+JbjdkTA==", "dev": true, "requires": { "chalk": "^5.3.0", @@ -9149,19 +9149,19 @@ "dev": true }, "playwright": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.1.tgz", - "integrity": "sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==", + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", + "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.40.1" + "playwright-core": "1.41.2" } }, "playwright-core": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.1.tgz", - "integrity": "sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==", + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", + "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", "dev": true }, "prelude-ls": { diff --git a/playwright/package.json b/playwright/package.json index cacbcbe8ab..ecd55ca3c6 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -7,17 +7,17 @@ "test:debug": "playwright test --debug" }, "devDependencies": { - "@playwright/test": "1.40.1", + "@playwright/test": "1.41.2", "eslint": "8.56.0", "eslint-config-standard": "17.1.0", "eslint-plugin-import": "2.29.1", "eslint-plugin-n": "16.6.2", "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "6.1.1", - "npm-check-updates": "16.14.12" + "npm-check-updates": "16.14.14" }, "dependencies": { - "@faker-js/faker": "8.3.1", + "@faker-js/faker": "8.4.0", "lodash": "4.17.21", "lodash-es": "4.17.21", "luxon": "3.4.4", diff --git a/yarn.lock b/yarn.lock index a4faa56b81..83ececbb95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2018,53 +2018,53 @@ __metadata: languageName: node linkType: hard -"@vue/compiler-core@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/compiler-core@npm:3.4.13" +"@vue/compiler-core@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/compiler-core@npm:3.4.15" dependencies: "@babel/parser": ^7.23.6 - "@vue/shared": 3.4.13 + "@vue/shared": 3.4.15 entities: ^4.5.0 estree-walker: ^2.0.2 source-map-js: ^1.0.2 - checksum: 5f486b5ca816db693f9cee44a8855f4de0bd83de2f423c10c800a19bcf2e864b74e04b75542948cd20baea4824e9c2eec2e492357a0cfe7f1a954177a9442b79 + checksum: 1610f715b8ab6de95aa9f904d484ed275cf39e947d3fbb92a8ff7d7178360b71cfeae2710ef819dbeb738e1f94bf191298449719a2ecc860389338bcdef220f5 languageName: node linkType: hard -"@vue/compiler-dom@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/compiler-dom@npm:3.4.13" +"@vue/compiler-dom@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/compiler-dom@npm:3.4.15" dependencies: - "@vue/compiler-core": 3.4.13 - "@vue/shared": 3.4.13 - checksum: 2afdacc03835425bd29a841a4d3a64bf0a60a53d73fc596933ce40e3577c45a7e06edc6f79207890b96a10f4f6bfd74e43ec4807253497fe55cf60db7e12204c + "@vue/compiler-core": 3.4.15 + "@vue/shared": 3.4.15 + checksum: 373968c2c603f4eb9ebbf5f31ca2dc89991c4c1b0cee0213e613ad8b4ee632a33174e92bd91e0f8ff65f55188b46b742b91269a098c1e421d8f8bc919d5adc25 languageName: node linkType: hard -"@vue/compiler-sfc@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/compiler-sfc@npm:3.4.13" +"@vue/compiler-sfc@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/compiler-sfc@npm:3.4.15" dependencies: "@babel/parser": ^7.23.6 - "@vue/compiler-core": 3.4.13 - "@vue/compiler-dom": 3.4.13 - "@vue/compiler-ssr": 3.4.13 - "@vue/shared": 3.4.13 + "@vue/compiler-core": 3.4.15 + "@vue/compiler-dom": 3.4.15 + "@vue/compiler-ssr": 3.4.15 + "@vue/shared": 3.4.15 estree-walker: ^2.0.2 magic-string: ^0.30.5 - postcss: ^8.4.32 + postcss: ^8.4.33 source-map-js: ^1.0.2 - checksum: 9252b9f10c9f0d730afbd2a2963fbbd2784ffdfa0e9a35c3e0366c5081423c7cb091c35f663ee43d587ade2dea8ed4d0329db76b76d9dd5546c457a7ac65f95d + checksum: 4a707346c32b6deaec47c4bb1fddaaa6ec881e286db59de8922960f52a617ff7bebfcbe19e80c98a0fd91d0f575d962787f77c16ac10a7eaac7d938c48bfb4c7 languageName: node linkType: hard -"@vue/compiler-ssr@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/compiler-ssr@npm:3.4.13" +"@vue/compiler-ssr@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/compiler-ssr@npm:3.4.15" dependencies: - "@vue/compiler-dom": 3.4.13 - "@vue/shared": 3.4.13 - checksum: 99fae88e1312b138888e7df90064448a17f368d6e640f726f50233d261eb50e789cee83bc891b09015ea2a5fe0939db0b2c54c9b790e296991f5c420ebab1c20 + "@vue/compiler-dom": 3.4.15 + "@vue/shared": 3.4.15 + checksum: 45a12ae2dd2e645db53d43b3c27df1d8fbf0584199d6e5581c96b4566d889376f5da411f8e453e113e3dcae0f2cc80b6f6fb36110f3f42f5cc260e48a99dd37f languageName: node linkType: hard @@ -2075,52 +2075,52 @@ __metadata: languageName: node linkType: hard -"@vue/reactivity@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/reactivity@npm:3.4.13" +"@vue/reactivity@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/reactivity@npm:3.4.15" dependencies: - "@vue/shared": 3.4.13 - checksum: 883ba2fb31ce9366d51f686c793ebab4374610acb903706d6de095d737079692a6b87b6973b4170af2f363dd82c0d507f41ca49ec345f6b74665d152f4b8b0c8 + "@vue/shared": 3.4.15 + checksum: e1f8ef7ec3e933b5dd5e3aa3e281c38d1fd2834772016ea5193058d80342704afbed0e7728cf31eb5762c2705785eec98b3d154ae22005691bee5b35125a4d7c languageName: node linkType: hard -"@vue/runtime-core@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/runtime-core@npm:3.4.13" +"@vue/runtime-core@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/runtime-core@npm:3.4.15" dependencies: - "@vue/reactivity": 3.4.13 - "@vue/shared": 3.4.13 - checksum: 196c6c894d416c4a05d3811ff790d1bcc909220007a4aa3aafe03f85bf9d8e8c14dc9dbb063bccee2b4803c8581e50359fc1417e4e786d481e2cfd26f8299358 + "@vue/reactivity": 3.4.15 + "@vue/shared": 3.4.15 + checksum: 6ab6721410ce5379d3a0de8632527be5cae26adda33854bd32117cf395713d41980f47b3774ba4dfbe7242377397d61a5728aa14b6a0fbd9e8f77049ef1ca4a4 languageName: node linkType: hard -"@vue/runtime-dom@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/runtime-dom@npm:3.4.13" +"@vue/runtime-dom@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/runtime-dom@npm:3.4.15" dependencies: - "@vue/runtime-core": 3.4.13 - "@vue/shared": 3.4.13 + "@vue/runtime-core": 3.4.15 + "@vue/shared": 3.4.15 csstype: ^3.1.3 - checksum: 8811687c23e9f31e87bff8d97f9a20a9d78fe45b66f724fe4bcb2aa669a67328df615aa3bf5ea02a2e22a0c5459bab278e01b5fae31dc22c5e09e765df867bce + checksum: 4f2e79d95688dc110629d4879ce6cc9bdaf284a29636c28ea9bc5cb420649eaac7d1a545e11d54516311b0cfdc507a2979aaaf89e9eddd386d41ee36d29db60e languageName: node linkType: hard -"@vue/server-renderer@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/server-renderer@npm:3.4.13" +"@vue/server-renderer@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/server-renderer@npm:3.4.15" dependencies: - "@vue/compiler-ssr": 3.4.13 - "@vue/shared": 3.4.13 + "@vue/compiler-ssr": 3.4.15 + "@vue/shared": 3.4.15 peerDependencies: - vue: 3.4.13 - checksum: f17fff6af28f50bc552b5c798cb5ca595651863a52e62e4ed8b53448df870d2311e78ca1d513cf721168c3b17edd66700ccb7fe280372c84d0d8015787a786ee + vue: 3.4.15 + checksum: de93ccffe7008a12974d6f82024238f7b7b25817aae6846dabdcfb8534a6ce01528f7b13447b2561394112e4b6fd1bd125c3391c0ac9d849c6de167bf44f4e55 languageName: node linkType: hard -"@vue/shared@npm:3.4.13": - version: 3.4.13 - resolution: "@vue/shared@npm:3.4.13" - checksum: c514944886d08d85bde55dc4a116ac4c295f5fc003fd70f03bcb64e074c7367703611916cf05101c304c8df2ae91d0f9cddfd54175b94b070d02a90ff07d0411 +"@vue/shared@npm:3.4.15": + version: 3.4.15 + resolution: "@vue/shared@npm:3.4.15" + checksum: 237db3a880692c69358c46679562cee85d8495090a3c8ed44a4d4daa7c4a61d74e330b9bd1f3cec7362a2ae443f46186be8a86b44bff7604d5bd72ad994b8021 languageName: node linkType: hard @@ -2629,10 +2629,10 @@ browserlist@latest: languageName: node linkType: hard -"caniuse-lite@npm:1.0.30001576": - version: 1.0.30001576 - resolution: "caniuse-lite@npm:1.0.30001576" - checksum: b8b332675fe703d5e57b02df5f100345f2a3796c537a42422f5bfc82d3256b8bad3f4e2788553656d2650006d13a4b5db99725e2a9462cc0c8035ba494ba1857 +"caniuse-lite@npm:1.0.30001581": + version: 1.0.30001581 + resolution: "caniuse-lite@npm:1.0.30001581" + checksum: ca4e2cd9d0acf5e3c71fa2e7cd65561e4532d32b640145f634c333792074bb63de1239b35abfb6b6d372f97caf26f8d97faac7ba51ef190717ad2d3ae9c0d7a2 languageName: node linkType: hard @@ -6488,7 +6488,7 @@ browserlist@latest: languageName: node linkType: hard -"postcss@npm:^8.4.27": +"postcss@npm:^8.4.27, postcss@npm:^8.4.33": version: 8.4.33 resolution: "postcss@npm:8.4.33" dependencies: @@ -6499,17 +6499,6 @@ browserlist@latest: languageName: node linkType: hard -"postcss@npm:^8.4.32": - version: 8.4.32 - resolution: "postcss@npm:8.4.32" - dependencies: - nanoid: ^3.3.7 - picocolors: ^1.0.0 - source-map-js: ^1.0.2 - checksum: 220d9d0bf5d65be7ed31006c523bfb11619461d296245c1231831f90150aeb4a31eab9983ac9c5c89759a3ca8b60b3e0d098574964e1691673c3ce5c494305ae - languageName: node - linkType: hard - "posthtml-parser@npm:^0.10.1": version: 0.10.2 resolution: "posthtml-parser@npm:0.10.2" @@ -6988,7 +6977,7 @@ browserlist@latest: browser-fs-access: 0.35.0 browserlist: latest c8: 9.1.0 - caniuse-lite: 1.0.30001576 + caniuse-lite: 1.0.30001581 d3: 7.8.5 eslint: 8.56.0 eslint-config-standard: 17.1.0 @@ -7018,7 +7007,7 @@ browserlist@latest: pinia: 2.1.7 pinia-plugin-persist: 1.0.0 pug: 3.0.2 - sass: 1.69.7 + sass: 1.70.0 seedrandom: 3.0.5 select2: 4.1.0-rc.0 select2-bootstrap-5-theme: 1.3.0 @@ -7027,8 +7016,8 @@ browserlist@latest: slugify: 1.6.6 sortablejs: 1.15.2 vanillajs-datepicker: 1.3.4 - vite: 4.5.1 - vue: 3.4.13 + vite: 4.5.2 + vue: 3.4.15 vue-router: 4.2.5 zxcvbn: 4.4.2 languageName: unknown @@ -7094,16 +7083,16 @@ browserlist@latest: languageName: node linkType: hard -"sass@npm:1.69.7": - version: 1.69.7 - resolution: "sass@npm:1.69.7" +"sass@npm:1.70.0": + version: 1.70.0 + resolution: "sass@npm:1.70.0" dependencies: chokidar: ">=3.0.0 <4.0.0" immutable: ^4.0.0 source-map-js: ">=0.6.2 <2.0.0" bin: sass: sass.js - checksum: c67cd32b69fb26a50e4535353e4145de8cbc8187db07c467cc335157fd56d03cae98754f86efe43b880b29f20c0a168ab972c7f74ebfe234e2bd2dfb868890cb + checksum: fd1b622cf9b7fa699a03ec634611997552ece45eb98ac365fef22f42bdcb8ed63b326b64173379c966830c8551ae801e44e4a00d2de16fdadda2dc8f35400bbb languageName: node linkType: hard @@ -7811,9 +7800,9 @@ browserlist@latest: languageName: node linkType: hard -"vite@npm:4.5.1": - version: 4.5.1 - resolution: "vite@npm:4.5.1" +"vite@npm:4.5.2": + version: 4.5.2 + resolution: "vite@npm:4.5.2" dependencies: esbuild: ^0.18.10 fsevents: ~2.3.2 @@ -7847,7 +7836,7 @@ browserlist@latest: optional: true bin: vite: bin/vite.js - checksum: 72b3584b3d3b8d14e8a37f0248e47fb8b4d02ab35de5b5a8e5ca8ae55c3be2aab73760dc36edac4fa722de182f78cc492eb44888fcb4a9a0712c4605dad644f9 + checksum: 9d1f84f703c2660aced34deee7f309278ed368880f66e9570ac115c793d91f7fffb80ab19c602b3c8bc1341fe23437d86a3fcca2a9ef82f7ef0cdac5a40d0c86 languageName: node linkType: hard @@ -7929,21 +7918,21 @@ browserlist@latest: languageName: node linkType: hard -"vue@npm:3.4.13": - version: 3.4.13 - resolution: "vue@npm:3.4.13" +"vue@npm:3.4.15": + version: 3.4.15 + resolution: "vue@npm:3.4.15" dependencies: - "@vue/compiler-dom": 3.4.13 - "@vue/compiler-sfc": 3.4.13 - "@vue/runtime-dom": 3.4.13 - "@vue/server-renderer": 3.4.13 - "@vue/shared": 3.4.13 + "@vue/compiler-dom": 3.4.15 + "@vue/compiler-sfc": 3.4.15 + "@vue/runtime-dom": 3.4.15 + "@vue/server-renderer": 3.4.15 + "@vue/shared": 3.4.15 peerDependencies: typescript: "*" peerDependenciesMeta: typescript: optional: true - checksum: c9f8edf5fc8bcab2254a8b4cbcb9c6fa6c0f588521ecf98b8a315da1e87e817c50a2ab2d2f0339518bf9cbe252a558a44b36bef25825c11d8f9b1e214608b6c0 + checksum: 6e9ff02c9bd46cb47ff2225e7b51b75b00343b7f52076a56c2a90ce15de88c1de1aaa6b176ac39ca324479ee208b7f7e7992f54a353b0ee6b303081ac5ab30b0 languageName: node linkType: hard