diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 14a0d5ea90..ac7854f265 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,23 +33,16 @@ "oderwat.indent-rainbow", "redhat.vscode-yaml", "spmeesseman.vscode-taskexplorer", - "visualstudioexptteam.vscodeintellicode" + "visualstudioexptteam.vscodeintellicode", + "ms-python.pylint" ], "settings": { "terminal.integrated.defaultProfile.linux": "zsh", "python.pythonPath": "/usr/local/bin/python", "python.languageServer": "Default", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", "python.formatting.blackPath": "/usr/local/py-utils/bin/black", "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", - "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", - "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", - "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", - "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", - "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", "python.testing.pytestArgs": [ "ietf" ], diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4687d04aa9..3d36676762 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,11 +9,67 @@ updates: directory: "/" schedule: interval: "weekly" + reviewers: + - "ngpixel" - package-ecosystem: "docker" directory: "/docker" schedule: interval: "weekly" + reviewers: + - "ngpixel" - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" + reviewers: + - "rjsparks" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + reviewers: + - "ngpixel" + groups: + yarn: + patterns: + - "*" + - package-ecosystem: "npm" + directory: "/playwright" + schedule: + interval: "weekly" + reviewers: + - "ngpixel" + groups: + npm: + patterns: + - "*" + - package-ecosystem: "npm" + directory: "/dev/coverage-action" + schedule: + interval: "weekly" + reviewers: + - "ngpixel" + groups: + npm: + patterns: + - "*" + - package-ecosystem: "npm" + directory: "/dev/deploy-to-container" + schedule: + interval: "weekly" + reviewers: + - "ngpixel" + groups: + npm: + patterns: + - "*" + - package-ecosystem: "npm" + directory: "/dev/diff" + schedule: + interval: "weekly" + reviewers: + - "ngpixel" + groups: + npm: + patterns: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 251d576c07..6ba25ff55e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,11 @@ on: default: true required: true type: boolean + sandboxNoDbRefresh: + description: 'Sandbox Disable Daily DB Refresh' + default: false + required: true + type: boolean legacySandbox: description: 'Deploy to Legacy Sandbox' default: false @@ -54,9 +59,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + fetch-tags: false - - name: Get Next Version + - name: Get Next Version (Prod) if: ${{ github.ref_name == 'release' }} id: semver uses: ietf-tools/semver-action@v1 @@ -64,37 +70,48 @@ jobs: token: ${{ github.token }} branch: release skipInvalidTags: true - - - name: Set Next Version Env Var + + - name: Get Dev Version + if: ${{ github.ref_name != 'release' }} + id: semverdev + uses: ietf-tools/semver-action@v1 + with: + token: ${{ github.token }} + branch: release + skipInvalidTags: true + noVersionBumpBehavior: 'current' + noNewCommitBehavior: 'current' + + - name: Set Release Flag if: ${{ github.ref_name == 'release' }} run: | - echo "NEXT_VERSION=$nextStrict" >> $GITHUB_ENV + echo "IS_RELEASE=true" >> $GITHUB_ENV - name: Create Draft Release - uses: ncipollo/release-action@v1.13.0 + uses: ncipollo/release-action@v1.14.0 if: ${{ github.ref_name == 'release' }} with: prerelease: true draft: false commit: ${{ github.sha }} - tag: ${{ env.NEXT_VERSION }} - name: ${{ env.NEXT_VERSION }} + tag: ${{ steps.semver.outputs.nextStrict }} + name: ${{ steps.semver.outputs.nextStrict }} body: '*pending*' token: ${{ secrets.GITHUB_TOKEN }} - name: Set Build Variables id: buildvars run: | - if [[ $NEXT_VERSION ]]; then - echo "Using AUTO SEMVER mode: $NEXT_VERSION" + if [[ $IS_RELEASE ]]; then + echo "Using AUTO SEMVER mode: ${{ steps.semver.outputs.nextStrict }}" echo "should_deploy=true" >> $GITHUB_OUTPUT - echo "pkg_version=$NEXT_VERSION" >> $GITHUB_OUTPUT - echo "::notice::Release $NEXT_VERSION created using branch $GITHUB_REF_NAME" + echo "pkg_version=${{ steps.semver.outputs.nextStrict }}" >> $GITHUB_OUTPUT + echo "::notice::Release ${{ steps.semver.outputs.nextStrict }} created using branch $GITHUB_REF_NAME" else - echo "Using TEST mode: 11.0.0-dev.$GITHUB_RUN_NUMBER" + echo "Using TEST mode: ${{ steps.semverdev.outputs.nextMajorStrict }}.0.0-dev.$GITHUB_RUN_NUMBER" echo "should_deploy=false" >> $GITHUB_OUTPUT - echo "pkg_version=11.0.0-dev.$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT - echo "::notice::Non-production build 11.0.0-dev.$GITHUB_RUN_NUMBER created using branch $GITHUB_REF_NAME" + echo "pkg_version=${{ steps.semverdev.outputs.nextMajorStrict }}.0.0-dev.$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT + echo "::notice::Non-production build ${{ steps.semverdev.outputs.nextMajorStrict }}.0.0-dev.$GITHUB_RUN_NUMBER created using branch $GITHUB_REF_NAME" fi # ----------------------------------------------------------------- @@ -117,6 +134,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}} @@ -126,7 +146,8 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + fetch-tags: false - name: Setup Node.js uses: actions/setup-node@v4 @@ -134,26 +155,30 @@ jobs: node-version: '16' - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Download a Coverage Results if: ${{ github.event.inputs.skiptests == 'false' || github.ref_name == 'release' }} - uses: actions/download-artifact@v3.0.2 + uses: actions/download-artifact@v4.1.4 with: name: coverage - 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 @@ -171,7 +196,57 @@ 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: + DEBIAN_FRONTEND: noninteractive + AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_STATIC_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_STATIC_KEY_SECRET }} + AWS_DEFAULT_REGION: auto + 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/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 <> ~/.ssh/known_hosts - name: Remote SSH into VM - uses: appleboy/ssh-action@55dabf81b49d4120609345970c91507e2d734799 + uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31150ed99b..523d8d9d68 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,14 +52,14 @@ jobs: coverage xml - name: Upload geckodriver.log - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ failure() }} with: name: geckodriverlog path: geckodriver.log - name: Upload Coverage Results to Codecov - uses: codecov/codecov-action@v3.1.4 + uses: codecov/codecov-action@v4.1.0 with: files: coverage.xml @@ -69,7 +69,7 @@ jobs: mv latest-coverage.json coverage.json - name: Upload Coverage Results as Build Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ always() }} with: name: coverage @@ -103,7 +103,7 @@ jobs: npx playwright test --project=${{ matrix.project }} - name: Upload Report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ always() }} continue-on-error: true with: @@ -162,7 +162,7 @@ jobs: npx playwright test --project=${{ matrix.project }} -c playwright-legacy.config.js - name: Upload Report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ always() }} continue-on-error: true with: diff --git a/.gitignore b/.gitignore index 80e5f0228b..c25e6b5bfe 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,10 @@ datatracker.sublime-workspace /media /node_modules /release-coverage.json +/static /tmp-* /.testresult +*.swp *.pyc __pycache__ .yarn/* diff --git a/.pnp.cjs b/.pnp.cjs index 436846301a..0c62d9b23c 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -33,40 +33,41 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { [null, {\ "packageLocation": "./",\ "packageDependencies": [\ - ["@fullcalendar/bootstrap5", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ - ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/icalendar", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/interaction", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/list", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/luxon3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/timegrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/vue3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@parcel/optimizer-data-url", "npm:2.10.0"],\ - ["@parcel/transformer-inline-string", "npm:2.10.0"],\ - ["@parcel/transformer-sass", "npm:2.10.0"],\ + ["@fullcalendar/bootstrap5", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ + ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/icalendar", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/interaction", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/list", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/luxon3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/timegrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/vue3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@parcel/optimizer-data-url", "npm:2.12.0"],\ + ["@parcel/transformer-inline-string", "npm:2.12.0"],\ + ["@parcel/transformer-sass", "npm:2.12.0"],\ ["@popperjs/core", "npm:2.11.8"],\ - ["@rollup/pluginutils", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.0.5"],\ + ["@rollup/pluginutils", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.1.0"],\ ["@twuni/emojify", "npm:1.0.2"],\ - ["@vitejs/plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.4.0"],\ - ["bootstrap", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.2"],\ - ["bootstrap-icons", "npm:1.11.1"],\ + ["@vitejs/plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.6.2"],\ + ["@vue/language-plugin-pug", "npm:2.0.7"],\ + ["bootstrap", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.3"],\ + ["bootstrap-icons", "npm:1.11.3"],\ ["browser-fs-access", "npm:0.35.0"],\ ["browserlist", "npm:1.0.1"],\ - ["c8", "npm:8.0.1"],\ - ["caniuse-lite", "npm:1.0.30001538"],\ - ["d3", "npm:7.8.5"],\ - ["eslint", "npm:8.52.0"],\ + ["c8", "npm:9.1.0"],\ + ["caniuse-lite", "npm:1.0.30001599"],\ + ["d3", "npm:7.9.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-config-standard", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:17.1.0"],\ ["eslint-plugin-cypress", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.15.1"],\ - ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.0"],\ - ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.2.0"],\ + ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.1"],\ + ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.6.2"],\ ["eslint-plugin-node", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:11.1.0"],\ ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"],\ - ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.18.1"],\ + ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.23.0"],\ ["file-saver", "npm:2.0.5"],\ - ["highcharts", "npm:11.1.0"],\ - ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.7.0"],\ + ["highcharts", "npm:11.4.0"],\ + ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.16.0"],\ ["ical.js", "npm:1.5.0"],\ ["jquery", "npm:3.7.1"],\ ["jquery-migrate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.1"],\ @@ -74,28 +75,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["list.js", "npm:2.3.1"],\ ["lodash", "npm:4.17.21"],\ ["lodash-es", "npm:4.17.21"],\ - ["luxon", "npm:3.4.3"],\ - ["moment", "npm:2.29.4"],\ - ["moment-timezone", "npm:0.5.43"],\ + ["luxon", "npm:3.4.4"],\ + ["moment", "npm:2.30.1"],\ + ["moment-timezone", "npm:0.5.45"],\ ["ms", "npm:2.1.3"],\ ["murmurhash-js", "npm:1.0.0"],\ - ["naive-ui", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.35.0"],\ - ["parcel", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.10.0"],\ + ["naive-ui", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.38.1"],\ + ["parcel", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.12.0"],\ ["pinia", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.1.7"],\ ["pinia-plugin-persist", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:1.0.0"],\ ["pug", "npm:3.0.2"],\ - ["sass", "npm:1.69.4"],\ + ["sass", "npm:1.72.0"],\ ["seedrandom", "npm:3.0.5"],\ ["select2", "npm:4.1.0-rc.0"],\ ["select2-bootstrap-5-theme", "npm:1.3.0"],\ ["send", "npm:0.18.0"],\ ["shepherd.js", "npm:11.2.0"],\ ["slugify", "npm:1.6.6"],\ - ["sortablejs", "npm:1.15.0"],\ + ["sortablejs", "npm:1.15.2"],\ ["vanillajs-datepicker", "npm:1.3.4"],\ - ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.0"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"],\ - ["vue-router", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.2.5"],\ + ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.2"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"],\ + ["vue-router", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.3.0"],\ ["zxcvbn", "npm:4.4.2"]\ ],\ "linkType": "SOFT"\ @@ -150,10 +151,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["npm:7.23.0", {\ - "packageLocation": "./.yarn/cache/@babel-parser-npm-7.23.0-8a7b151672-453fdf8b9e.zip/node_modules/@babel/parser/",\ + ["npm:7.23.9", {\ + "packageLocation": "./.yarn/cache/@babel-parser-npm-7.23.9-720a0b56cb-e7cd4960ac.zip/node_modules/@babel/parser/",\ "packageDependencies": [\ - ["@babel/parser", "npm:7.23.0"],\ + ["@babel/parser", "npm:7.23.9"],\ ["@babel/types", "npm:7.18.4"]\ ],\ "linkType": "HARD"\ @@ -197,10 +198,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.15.12", {\ - "packageLocation": "./.yarn/__virtual__/@css-render-plugin-bem-virtual-3ee8479233/0/cache/@css-render-plugin-bem-npm-0.15.12-bf8b43dc1f-9fa7ddd62b.zip/node_modules/@css-render/plugin-bem/",\ + ["virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.15.12", {\ + "packageLocation": "./.yarn/__virtual__/@css-render-plugin-bem-virtual-105b1b654b/0/cache/@css-render-plugin-bem-npm-0.15.12-bf8b43dc1f-9fa7ddd62b.zip/node_modules/@css-render/plugin-bem/",\ "packageDependencies": [\ - ["@css-render/plugin-bem", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.15.12"],\ + ["@css-render/plugin-bem", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.15.12"],\ ["@types/css-render", null],\ ["css-render", "npm:0.15.12"]\ ],\ @@ -226,12 +227,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:07229bbf54bc488d21e48f65df3fcd2cdabd1e401dfffccce7403d04695be90e478a0d508694f896481602b0f9db804b9f384dfa051fe08e896fd18fd1fe0b6b#npm:0.15.10", {\ - "packageLocation": "./.yarn/__virtual__/@css-render-vue3-ssr-virtual-5eb3a62c1f/0/cache/@css-render-vue3-ssr-npm-0.15.10-b8526cc313-7977e0c440.zip/node_modules/@css-render/vue3-ssr/",\ + ["virtual:2366be83ef58a728ebb5a5e9ed4600f4465f98b2a844262fcfbe89415361d5d5f9e964ec3b9a72d6a5004f37c1024d017c65e67473dd9cc39cd61f51768c65e6#npm:0.15.10", {\ + "packageLocation": "./.yarn/__virtual__/@css-render-vue3-ssr-virtual-8cb63dbe2e/0/cache/@css-render-vue3-ssr-npm-0.15.10-b8526cc313-7977e0c440.zip/node_modules/@css-render/vue3-ssr/",\ "packageDependencies": [\ - ["@css-render/vue3-ssr", "virtual:07229bbf54bc488d21e48f65df3fcd2cdabd1e401dfffccce7403d04695be90e478a0d508694f896481602b0f9db804b9f384dfa051fe08e896fd18fd1fe0b6b#npm:0.15.10"],\ + ["@css-render/vue3-ssr", "virtual:2366be83ef58a728ebb5a5e9ed4600f4465f98b2a844262fcfbe89415361d5d5f9e964ec3b9a72d6a5004f37c1024d017c65e67473dd9cc39cd61f51768c65e6#npm:0.15.10"],\ ["@types/vue", null],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -239,12 +240,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.15.12", {\ - "packageLocation": "./.yarn/__virtual__/@css-render-vue3-ssr-virtual-f9b68b2e9d/0/cache/@css-render-vue3-ssr-npm-0.15.12-a130f4db3a-a5505ae161.zip/node_modules/@css-render/vue3-ssr/",\ + ["virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.15.12", {\ + "packageLocation": "./.yarn/__virtual__/@css-render-vue3-ssr-virtual-18db73fb22/0/cache/@css-render-vue3-ssr-npm-0.15.12-a130f4db3a-a5505ae161.zip/node_modules/@css-render/vue3-ssr/",\ "packageDependencies": [\ - ["@css-render/vue3-ssr", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.15.12"],\ + ["@css-render/vue3-ssr", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.15.12"],\ ["@types/vue", null],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -468,12 +469,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:e7f048a4398cf12bbd1cde95eff45a3327b9fe42e04c31ed99d0e926830d0b78ff78b511118c2bd3bc6add84782ab97a5b001e972071d6fdbfe85a86c150922a#npm:4.4.0", {\ - "packageLocation": "./.yarn/__virtual__/@eslint-community-eslint-utils-virtual-a282c466c0/0/cache/@eslint-community-eslint-utils-npm-4.4.0-d1791bd5a3-cdfe3ae42b.zip/node_modules/@eslint-community/eslint-utils/",\ + ["virtual:4286e12a3a0f74af013bc8f16c6d8fdde823cfbf6389660266b171e551f576c805b0a7a8eb2a7087a5cee7dfe6ebb6e1ea3808d93daf915edc95656907a381bb#npm:4.4.0", {\ + "packageLocation": "./.yarn/__virtual__/@eslint-community-eslint-utils-virtual-1c7da85a1a/0/cache/@eslint-community-eslint-utils-npm-4.4.0-d1791bd5a3-cdfe3ae42b.zip/node_modules/@eslint-community/eslint-utils/",\ "packageDependencies": [\ - ["@eslint-community/eslint-utils", "virtual:e7f048a4398cf12bbd1cde95eff45a3327b9fe42e04c31ed99d0e926830d0b78ff78b511118c2bd3bc6add84782ab97a5b001e972071d6fdbfe85a86c150922a#npm:4.4.0"],\ + ["@eslint-community/eslint-utils", "virtual:4286e12a3a0f74af013bc8f16c6d8fdde823cfbf6389660266b171e551f576c805b0a7a8eb2a7087a5cee7dfe6ebb6e1ea3808d93daf915edc95656907a381bb#npm:4.4.0"],\ ["@types/eslint", null],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-visitor-keys", "npm:3.3.0"]\ ],\ "packagePeers": [\ @@ -484,10 +485,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@eslint-community/regexpp", [\ - ["npm:4.5.1", {\ - "packageLocation": "./.yarn/cache/@eslint-community-regexpp-npm-4.5.1-bf72922237-6d901166d6.zip/node_modules/@eslint-community/regexpp/",\ + ["npm:4.10.0", {\ + "packageLocation": "./.yarn/cache/@eslint-community-regexpp-npm-4.10.0-6bfb984c81-2a6e345429.zip/node_modules/@eslint-community/regexpp/",\ "packageDependencies": [\ - ["@eslint-community/regexpp", "npm:4.5.1"]\ + ["@eslint-community/regexpp", "npm:4.10.0"]\ ],\ "linkType": "HARD"\ }],\ @@ -500,10 +501,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@eslint/eslintrc", [\ - ["npm:2.1.2", {\ - "packageLocation": "./.yarn/cache/@eslint-eslintrc-npm-2.1.2-feb0771c9f-bc742a1e3b.zip/node_modules/@eslint/eslintrc/",\ + ["npm:2.1.4", {\ + "packageLocation": "./.yarn/cache/@eslint-eslintrc-npm-2.1.4-1ff4b5f908-10957c7592.zip/node_modules/@eslint/eslintrc/",\ "packageDependencies": [\ - ["@eslint/eslintrc", "npm:2.1.2"],\ + ["@eslint/eslintrc", "npm:2.1.4"],\ ["ajv", "npm:6.12.6"],\ ["debug", "virtual:b86a9fb34323a98c6519528ed55faa0d9b44ca8879307c0b29aa384bde47ff59a7d0c9051b31246f14521dfb71ba3c5d6d0b35c29fffc17bf875aa6ad977d9e8#npm:4.3.4"],\ ["espree", "npm:9.6.1"],\ @@ -518,10 +519,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@eslint/js", [\ - ["npm:8.52.0", {\ - "packageLocation": "./.yarn/cache/@eslint-js-npm-8.52.0-801dbdf7b0-490893b809.zip/node_modules/@eslint/js/",\ + ["npm:8.57.0", {\ + "packageLocation": "./.yarn/cache/@eslint-js-npm-8.57.0-00ead3710a-315dc65b0e.zip/node_modules/@eslint/js/",\ "packageDependencies": [\ - ["@eslint/js", "npm:8.52.0"]\ + ["@eslint/js", "npm:8.57.0"]\ ],\ "linkType": "HARD"\ }]\ @@ -557,18 +558,18 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/bootstrap5", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-bootstrap5-npm-6.1.9-ef68c3c094-1d6168fafc.zip/node_modules/@fullcalendar/bootstrap5/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-bootstrap5-npm-6.1.11-6e0fbf281a-a0c3b94346.zip/node_modules/@fullcalendar/bootstrap5/",\ "packageDependencies": [\ - ["@fullcalendar/bootstrap5", "npm:6.1.9"]\ + ["@fullcalendar/bootstrap5", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-bootstrap5-virtual-32a9d3c1d6/0/cache/@fullcalendar-bootstrap5-npm-6.1.9-ef68c3c094-1d6168fafc.zip/node_modules/@fullcalendar/bootstrap5/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-bootstrap5-virtual-50942c1c6f/0/cache/@fullcalendar-bootstrap5-npm-6.1.11-6e0fbf281a-a0c3b94346.zip/node_modules/@fullcalendar/bootstrap5/",\ "packageDependencies": [\ - ["@fullcalendar/bootstrap5", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/bootstrap5", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["@types/fullcalendar__core", null]\ ],\ "packagePeers": [\ @@ -579,28 +580,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/core", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-core-npm-6.1.9-b4da84d4b8-836db3e40c.zip/node_modules/@fullcalendar/core/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-core-npm-6.1.11-ae049c8ace-0078a6f96b.zip/node_modules/@fullcalendar/core/",\ "packageDependencies": [\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["preact", "npm:10.12.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@fullcalendar/daygrid", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-daygrid-npm-6.1.9-4c0da59f84-3db55247c4.zip/node_modules/@fullcalendar/daygrid/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-daygrid-npm-6.1.11-2187ca1b8f-6eb5606de5.zip/node_modules/@fullcalendar/daygrid/",\ "packageDependencies": [\ - ["@fullcalendar/daygrid", "npm:6.1.9"]\ + ["@fullcalendar/daygrid", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-daygrid-virtual-8030f0f5bf/0/cache/@fullcalendar-daygrid-npm-6.1.9-4c0da59f84-3db55247c4.zip/node_modules/@fullcalendar/daygrid/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-daygrid-virtual-b91d1ffe14/0/cache/@fullcalendar-daygrid-npm-6.1.11-2187ca1b8f-6eb5606de5.zip/node_modules/@fullcalendar/daygrid/",\ "packageDependencies": [\ - ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["@types/fullcalendar__core", null]\ ],\ "packagePeers": [\ @@ -611,18 +612,18 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/icalendar", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-icalendar-npm-6.1.9-92e390eda8-d47daf4ae0.zip/node_modules/@fullcalendar/icalendar/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-icalendar-npm-6.1.11-73807e790d-4e6eff15a8.zip/node_modules/@fullcalendar/icalendar/",\ "packageDependencies": [\ - ["@fullcalendar/icalendar", "npm:6.1.9"]\ + ["@fullcalendar/icalendar", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-icalendar-virtual-2edf12646d/0/cache/@fullcalendar-icalendar-npm-6.1.9-92e390eda8-d47daf4ae0.zip/node_modules/@fullcalendar/icalendar/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-icalendar-virtual-636a290006/0/cache/@fullcalendar-icalendar-npm-6.1.11-73807e790d-4e6eff15a8.zip/node_modules/@fullcalendar/icalendar/",\ "packageDependencies": [\ - ["@fullcalendar/icalendar", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/icalendar", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["@types/fullcalendar__core", null],\ ["@types/ical.js", null],\ ["ical.js", "npm:1.5.0"]\ @@ -637,18 +638,18 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/interaction", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-interaction-npm-6.1.9-f729b81a3d-787111ea6f.zip/node_modules/@fullcalendar/interaction/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-interaction-npm-6.1.11-39630596c7-c67d4cfa0b.zip/node_modules/@fullcalendar/interaction/",\ "packageDependencies": [\ - ["@fullcalendar/interaction", "npm:6.1.9"]\ + ["@fullcalendar/interaction", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-interaction-virtual-45406e4d3a/0/cache/@fullcalendar-interaction-npm-6.1.9-f729b81a3d-787111ea6f.zip/node_modules/@fullcalendar/interaction/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-interaction-virtual-3ebf8b0646/0/cache/@fullcalendar-interaction-npm-6.1.11-39630596c7-c67d4cfa0b.zip/node_modules/@fullcalendar/interaction/",\ "packageDependencies": [\ - ["@fullcalendar/interaction", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/interaction", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["@types/fullcalendar__core", null]\ ],\ "packagePeers": [\ @@ -659,18 +660,18 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/list", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-list-npm-6.1.9-f76695c5ab-978dd54b71.zip/node_modules/@fullcalendar/list/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-list-npm-6.1.11-8f1846f302-84a8cd6e63.zip/node_modules/@fullcalendar/list/",\ "packageDependencies": [\ - ["@fullcalendar/list", "npm:6.1.9"]\ + ["@fullcalendar/list", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-list-virtual-95391a7d25/0/cache/@fullcalendar-list-npm-6.1.9-f76695c5ab-978dd54b71.zip/node_modules/@fullcalendar/list/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-list-virtual-1c555df506/0/cache/@fullcalendar-list-npm-6.1.11-8f1846f302-84a8cd6e63.zip/node_modules/@fullcalendar/list/",\ "packageDependencies": [\ - ["@fullcalendar/list", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/list", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["@types/fullcalendar__core", null]\ ],\ "packagePeers": [\ @@ -681,21 +682,21 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/luxon3", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-luxon3-npm-6.1.9-d79fc8f961-25122126e2.zip/node_modules/@fullcalendar/luxon3/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-luxon3-npm-6.1.11-3e90656a71-8e7f45aab2.zip/node_modules/@fullcalendar/luxon3/",\ "packageDependencies": [\ - ["@fullcalendar/luxon3", "npm:6.1.9"]\ + ["@fullcalendar/luxon3", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-luxon3-virtual-2026214153/0/cache/@fullcalendar-luxon3-npm-6.1.9-d79fc8f961-25122126e2.zip/node_modules/@fullcalendar/luxon3/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-luxon3-virtual-38643019c2/0/cache/@fullcalendar-luxon3-npm-6.1.11-3e90656a71-8e7f45aab2.zip/node_modules/@fullcalendar/luxon3/",\ "packageDependencies": [\ - ["@fullcalendar/luxon3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/luxon3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["@types/fullcalendar__core", null],\ ["@types/luxon", null],\ - ["luxon", "npm:3.4.3"]\ + ["luxon", "npm:3.4.4"]\ ],\ "packagePeers": [\ "@fullcalendar/core",\ @@ -707,19 +708,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/timegrid", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-timegrid-npm-6.1.9-b227fefa80-8c12a508f7.zip/node_modules/@fullcalendar/timegrid/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-timegrid-npm-6.1.11-1d43455bfd-4a11e6dd90.zip/node_modules/@fullcalendar/timegrid/",\ "packageDependencies": [\ - ["@fullcalendar/timegrid", "npm:6.1.9"]\ + ["@fullcalendar/timegrid", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-timegrid-virtual-6658ed7986/0/cache/@fullcalendar-timegrid-npm-6.1.9-b227fefa80-8c12a508f7.zip/node_modules/@fullcalendar/timegrid/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-timegrid-virtual-5e951d78a6/0/cache/@fullcalendar-timegrid-npm-6.1.11-1d43455bfd-4a11e6dd90.zip/node_modules/@fullcalendar/timegrid/",\ "packageDependencies": [\ - ["@fullcalendar/timegrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ - ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ + ["@fullcalendar/timegrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ + ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ ["@types/fullcalendar__core", null]\ ],\ "packagePeers": [\ @@ -730,21 +731,21 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@fullcalendar/vue3", [\ - ["npm:6.1.9", {\ - "packageLocation": "./.yarn/cache/@fullcalendar-vue3-npm-6.1.9-3c150e259d-2c1c0fbe72.zip/node_modules/@fullcalendar/vue3/",\ + ["npm:6.1.11", {\ + "packageLocation": "./.yarn/cache/@fullcalendar-vue3-npm-6.1.11-f6b8b48da4-5891a596e9.zip/node_modules/@fullcalendar/vue3/",\ "packageDependencies": [\ - ["@fullcalendar/vue3", "npm:6.1.9"]\ + ["@fullcalendar/vue3", "npm:6.1.11"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9", {\ - "packageLocation": "./.yarn/__virtual__/@fullcalendar-vue3-virtual-00c58bdbde/0/cache/@fullcalendar-vue3-npm-6.1.9-3c150e259d-2c1c0fbe72.zip/node_modules/@fullcalendar/vue3/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11", {\ + "packageLocation": "./.yarn/__virtual__/@fullcalendar-vue3-virtual-cb317bc2d1/0/cache/@fullcalendar-vue3-npm-6.1.11-f6b8b48da4-5891a596e9.zip/node_modules/@fullcalendar/vue3/",\ "packageDependencies": [\ - ["@fullcalendar/vue3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ + ["@fullcalendar/vue3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ ["@types/fullcalendar__core", null],\ ["@types/vue", null],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@fullcalendar/core",\ @@ -775,11 +776,11 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@humanwhocodes/config-array", [\ - ["npm:0.11.13", {\ - "packageLocation": "./.yarn/cache/@humanwhocodes-config-array-npm-0.11.13-12314014f2-f8ea57b0d7.zip/node_modules/@humanwhocodes/config-array/",\ + ["npm:0.11.14", {\ + "packageLocation": "./.yarn/cache/@humanwhocodes-config-array-npm-0.11.14-94a02fcc87-861ccce9ea.zip/node_modules/@humanwhocodes/config-array/",\ "packageDependencies": [\ - ["@humanwhocodes/config-array", "npm:0.11.13"],\ - ["@humanwhocodes/object-schema", "npm:2.0.1"],\ + ["@humanwhocodes/config-array", "npm:0.11.14"],\ + ["@humanwhocodes/object-schema", "npm:2.0.2"],\ ["debug", "virtual:b86a9fb34323a98c6519528ed55faa0d9b44ca8879307c0b29aa384bde47ff59a7d0c9051b31246f14521dfb71ba3c5d6d0b35c29fffc17bf875aa6ad977d9e8#npm:4.3.4"],\ ["minimatch", "npm:3.1.2"]\ ],\ @@ -796,10 +797,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@humanwhocodes/object-schema", [\ - ["npm:2.0.1", {\ - "packageLocation": "./.yarn/cache/@humanwhocodes-object-schema-npm-2.0.1-c23364bbfc-24929487b1.zip/node_modules/@humanwhocodes/object-schema/",\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/@humanwhocodes-object-schema-npm-2.0.2-77b42018f9-2fc1150336.zip/node_modules/@humanwhocodes/object-schema/",\ "packageDependencies": [\ - ["@humanwhocodes/object-schema", "npm:2.0.1"]\ + ["@humanwhocodes/object-schema", "npm:2.0.2"]\ ],\ "linkType": "HARD"\ }]\ @@ -1159,25 +1160,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/bundler-default", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-bundler-default-npm-2.10.0-bf1aa01515-58d3619928.zip/node_modules/@parcel/bundler-default/",\ - "packageDependencies": [\ - ["@parcel/bundler-default", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/graph", "npm:3.0.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-bundler-default-npm-2.12.0-9ba57d919c-f211a76f55.zip/node_modules/@parcel/bundler-default/",\ + "packageDependencies": [\ + ["@parcel/bundler-default", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/graph", "npm:3.2.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/cache", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-cache-npm-2.10.0-37f1f83d32-209d474abd.zip/node_modules/@parcel/cache/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-cache-npm-2.12.0-3389909f2c-a45e799809.zip/node_modules/@parcel/cache/",\ "packageDependencies": [\ - ["@parcel/cache", "npm:2.10.0"]\ + ["@parcel/cache", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ @@ -1188,30 +1189,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-cache-virtual-ef6118146d/0/cache/@parcel-cache-npm-2.10.0-37f1f83d32-209d474abd.zip/node_modules/@parcel/cache/",\ - "packageDependencies": [\ - ["@parcel/cache", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/core", "npm:2.6.2"],\ - ["@parcel/fs", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@types/parcel__core", null],\ - ["lmdb", "npm:2.8.5"]\ - ],\ - "packagePeers": [\ - "@types/parcel__core"\ - ],\ - "linkType": "HARD"\ - }],\ - ["virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-cache-virtual-e54076624a/0/cache/@parcel-cache-npm-2.10.0-37f1f83d32-209d474abd.zip/node_modules/@parcel/cache/",\ - "packageDependencies": [\ - ["@parcel/cache", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/fs", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-cache-virtual-a2e9499dbb/0/cache/@parcel-cache-npm-2.12.0-3389909f2c-a45e799809.zip/node_modules/@parcel/cache/",\ + "packageDependencies": [\ + ["@parcel/cache", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/fs", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["@types/parcel__core", null],\ ["lmdb", "npm:2.8.5"]\ ],\ @@ -1236,13 +1221,29 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "@types/parcel__core"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-cache-virtual-6f5cc88243/0/cache/@parcel-cache-npm-2.12.0-3389909f2c-a45e799809.zip/node_modules/@parcel/cache/",\ + "packageDependencies": [\ + ["@parcel/cache", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/core", "npm:2.6.2"],\ + ["@parcel/fs", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@types/parcel__core", null],\ + ["lmdb", "npm:2.8.5"]\ + ],\ + "packagePeers": [\ + "@types/parcel__core"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@parcel/codeframe", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-codeframe-npm-2.10.0-e8aa1b4ecc-d87b17d3ce.zip/node_modules/@parcel/codeframe/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-codeframe-npm-2.12.0-aa8027940e-265c4d7ebe.zip/node_modules/@parcel/codeframe/",\ "packageDependencies": [\ - ["@parcel/codeframe", "npm:2.10.0"],\ + ["@parcel/codeframe", "npm:2.12.0"],\ ["chalk", "npm:4.1.2"]\ ],\ "linkType": "HARD"\ @@ -1257,59 +1258,59 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/compressor-raw", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-compressor-raw-npm-2.10.0-961e5d9fe0-043fca0ecb.zip/node_modules/@parcel/compressor-raw/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-compressor-raw-npm-2.12.0-19f313c172-16c56704f3.zip/node_modules/@parcel/compressor-raw/",\ "packageDependencies": [\ - ["@parcel/compressor-raw", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"]\ + ["@parcel/compressor-raw", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/config-default", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-config-default-npm-2.10.0-2a1fbdf24b-d780d05021.zip/node_modules/@parcel/config-default/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-config-default-npm-2.12.0-aefd3c699e-72877c5dc4.zip/node_modules/@parcel/config-default/",\ "packageDependencies": [\ - ["@parcel/config-default", "npm:2.10.0"]\ + ["@parcel/config-default", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:71592776e81a3a98123fea990d2adcb9a2eb4cc84ca35ac4be3a6f331fe8d1f764a124c4f9a2dad3afd35076e01667fb0ef9ccd5629fbe405b31f0d1b14a14fd#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-config-default-virtual-61dcbb3314/0/cache/@parcel-config-default-npm-2.10.0-2a1fbdf24b-d780d05021.zip/node_modules/@parcel/config-default/",\ - "packageDependencies": [\ - ["@parcel/config-default", "virtual:71592776e81a3a98123fea990d2adcb9a2eb4cc84ca35ac4be3a6f331fe8d1f764a124c4f9a2dad3afd35076e01667fb0ef9ccd5629fbe405b31f0d1b14a14fd#npm:2.10.0"],\ - ["@parcel/bundler-default", "npm:2.10.0"],\ - ["@parcel/compressor-raw", "npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/namer-default", "npm:2.10.0"],\ - ["@parcel/optimizer-css", "npm:2.10.0"],\ - ["@parcel/optimizer-htmlnano", "npm:2.10.0"],\ - ["@parcel/optimizer-image", "virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0"],\ - ["@parcel/optimizer-svgo", "npm:2.10.0"],\ - ["@parcel/optimizer-swc", "npm:2.10.0"],\ - ["@parcel/packager-css", "npm:2.10.0"],\ - ["@parcel/packager-html", "npm:2.10.0"],\ - ["@parcel/packager-js", "npm:2.10.0"],\ - ["@parcel/packager-raw", "npm:2.10.0"],\ - ["@parcel/packager-svg", "npm:2.10.0"],\ - ["@parcel/packager-wasm", "npm:2.10.0"],\ - ["@parcel/reporter-dev-server", "npm:2.10.0"],\ - ["@parcel/resolver-default", "npm:2.10.0"],\ - ["@parcel/runtime-browser-hmr", "npm:2.10.0"],\ - ["@parcel/runtime-js", "npm:2.10.0"],\ - ["@parcel/runtime-react-refresh", "npm:2.10.0"],\ - ["@parcel/runtime-service-worker", "npm:2.10.0"],\ - ["@parcel/transformer-babel", "npm:2.10.0"],\ - ["@parcel/transformer-css", "npm:2.10.0"],\ - ["@parcel/transformer-html", "npm:2.10.0"],\ - ["@parcel/transformer-image", "virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0"],\ - ["@parcel/transformer-js", "virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0"],\ - ["@parcel/transformer-json", "npm:2.10.0"],\ - ["@parcel/transformer-postcss", "npm:2.10.0"],\ - ["@parcel/transformer-posthtml", "npm:2.10.0"],\ - ["@parcel/transformer-raw", "npm:2.10.0"],\ - ["@parcel/transformer-react-refresh-wrap", "npm:2.10.0"],\ - ["@parcel/transformer-svg", "npm:2.10.0"],\ + ["virtual:fdd74b573cf769bcde15fb47c39fbe0d73f59838182900fd59d3d43b2214ea01b1d45084fb49d0c192fc3e8a49adea5782afcb7fe14e09c63bedaf09f4939e35#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-config-default-virtual-284acdc258/0/cache/@parcel-config-default-npm-2.12.0-aefd3c699e-72877c5dc4.zip/node_modules/@parcel/config-default/",\ + "packageDependencies": [\ + ["@parcel/config-default", "virtual:fdd74b573cf769bcde15fb47c39fbe0d73f59838182900fd59d3d43b2214ea01b1d45084fb49d0c192fc3e8a49adea5782afcb7fe14e09c63bedaf09f4939e35#npm:2.12.0"],\ + ["@parcel/bundler-default", "npm:2.12.0"],\ + ["@parcel/compressor-raw", "npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/namer-default", "npm:2.12.0"],\ + ["@parcel/optimizer-css", "npm:2.12.0"],\ + ["@parcel/optimizer-htmlnano", "npm:2.12.0"],\ + ["@parcel/optimizer-image", "virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0"],\ + ["@parcel/optimizer-svgo", "npm:2.12.0"],\ + ["@parcel/optimizer-swc", "npm:2.12.0"],\ + ["@parcel/packager-css", "npm:2.12.0"],\ + ["@parcel/packager-html", "npm:2.12.0"],\ + ["@parcel/packager-js", "npm:2.12.0"],\ + ["@parcel/packager-raw", "npm:2.12.0"],\ + ["@parcel/packager-svg", "npm:2.12.0"],\ + ["@parcel/packager-wasm", "npm:2.12.0"],\ + ["@parcel/reporter-dev-server", "npm:2.12.0"],\ + ["@parcel/resolver-default", "npm:2.12.0"],\ + ["@parcel/runtime-browser-hmr", "npm:2.12.0"],\ + ["@parcel/runtime-js", "npm:2.12.0"],\ + ["@parcel/runtime-react-refresh", "npm:2.12.0"],\ + ["@parcel/runtime-service-worker", "npm:2.12.0"],\ + ["@parcel/transformer-babel", "npm:2.12.0"],\ + ["@parcel/transformer-css", "npm:2.12.0"],\ + ["@parcel/transformer-html", "npm:2.12.0"],\ + ["@parcel/transformer-image", "virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0"],\ + ["@parcel/transformer-js", "virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0"],\ + ["@parcel/transformer-json", "npm:2.12.0"],\ + ["@parcel/transformer-postcss", "npm:2.12.0"],\ + ["@parcel/transformer-posthtml", "npm:2.12.0"],\ + ["@parcel/transformer-raw", "npm:2.12.0"],\ + ["@parcel/transformer-react-refresh-wrap", "npm:2.12.0"],\ + ["@parcel/transformer-svg", "npm:2.12.0"],\ ["@types/parcel__core", null]\ ],\ "packagePeers": [\ @@ -1320,25 +1321,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/core", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-core-npm-2.10.0-59eaeeba7a-c59c2971ea.zip/node_modules/@parcel/core/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-core-npm-2.12.0-8f08b883d4-5bf6746308.zip/node_modules/@parcel/core/",\ "packageDependencies": [\ - ["@parcel/core", "npm:2.10.0"],\ + ["@parcel/core", "npm:2.12.0"],\ ["@mischnic/json-sourcemap", "npm:0.1.0"],\ - ["@parcel/cache", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/events", "npm:2.10.0"],\ - ["@parcel/fs", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/graph", "npm:3.0.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/package-manager", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/profiler", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ + ["@parcel/cache", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/events", "npm:2.12.0"],\ + ["@parcel/fs", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/graph", "npm:3.2.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/package-manager", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/profiler", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/workers", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/workers", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ ["abortcontroller-polyfill", "npm:1.7.3"],\ ["base-x", "npm:3.0.9"],\ ["browserslist", "npm:4.20.3"],\ @@ -1346,7 +1347,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["dotenv", "npm:7.0.0"],\ ["dotenv-expand", "npm:5.1.0"],\ ["json5", "npm:2.2.1"],\ - ["msgpackr", "npm:1.6.0"],\ + ["msgpackr", "npm:1.10.1"],\ ["nullthrows", "npm:1.1.1"],\ ["semver", "npm:7.5.4"]\ ],\ @@ -1385,10 +1386,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/diagnostic", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-diagnostic-npm-2.10.0-1e389b369e-45c606ca52.zip/node_modules/@parcel/diagnostic/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-diagnostic-npm-2.12.0-6e89ddad28-a4b918c1a0.zip/node_modules/@parcel/diagnostic/",\ "packageDependencies": [\ - ["@parcel/diagnostic", "npm:2.10.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ ["@mischnic/json-sourcemap", "npm:0.1.0"],\ ["nullthrows", "npm:1.1.1"]\ ],\ @@ -1405,10 +1406,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/events", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-events-npm-2.10.0-da42a4afa6-1d21cd4186.zip/node_modules/@parcel/events/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-events-npm-2.12.0-e6eff18c8c-136a8a2921.zip/node_modules/@parcel/events/",\ "packageDependencies": [\ - ["@parcel/events", "npm:2.10.0"]\ + ["@parcel/events", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }],\ @@ -1421,10 +1422,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/fs", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-fs-npm-2.10.0-c959567f0f-10faae481c.zip/node_modules/@parcel/fs/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-fs-npm-2.12.0-3c46842e62-43d454d55d.zip/node_modules/@parcel/fs/",\ "packageDependencies": [\ - ["@parcel/fs", "npm:2.10.0"]\ + ["@parcel/fs", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ @@ -1435,33 +1436,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-fs-virtual-eac6fac48b/0/cache/@parcel-fs-npm-2.10.0-c959567f0f-10faae481c.zip/node_modules/@parcel/fs/",\ - "packageDependencies": [\ - ["@parcel/fs", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/core", "npm:2.6.2"],\ - ["@parcel/rust", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/watcher", "npm:2.0.7"],\ - ["@parcel/workers", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@types/parcel__core", null]\ - ],\ - "packagePeers": [\ - "@types/parcel__core"\ - ],\ - "linkType": "HARD"\ - }],\ - ["virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-fs-virtual-e5737ffb6b/0/cache/@parcel-fs-npm-2.10.0-c959567f0f-10faae481c.zip/node_modules/@parcel/fs/",\ - "packageDependencies": [\ - ["@parcel/fs", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-fs-virtual-762e5c5add/0/cache/@parcel-fs-npm-2.12.0-3c46842e62-43d454d55d.zip/node_modules/@parcel/fs/",\ + "packageDependencies": [\ + ["@parcel/fs", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["@parcel/watcher", "npm:2.0.7"],\ - ["@parcel/workers", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ + ["@parcel/workers", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ ["@types/parcel__core", null]\ ],\ "packagePeers": [\ @@ -1486,6 +1470,23 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "@types/parcel__core"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-fs-virtual-ae7dde1116/0/cache/@parcel-fs-npm-2.12.0-3c46842e62-43d454d55d.zip/node_modules/@parcel/fs/",\ + "packageDependencies": [\ + ["@parcel/fs", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/core", "npm:2.6.2"],\ + ["@parcel/rust", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/watcher", "npm:2.0.7"],\ + ["@parcel/workers", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@types/parcel__core", null]\ + ],\ + "packagePeers": [\ + "@types/parcel__core"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@parcel/fs-search", [\ @@ -1508,10 +1509,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["npm:3.0.0", {\ - "packageLocation": "./.yarn/cache/@parcel-graph-npm-3.0.0-9001abfefc-0a9d5017f6.zip/node_modules/@parcel/graph/",\ + ["npm:3.2.0", {\ + "packageLocation": "./.yarn/cache/@parcel-graph-npm-3.2.0-92821d4289-b4d31624fc.zip/node_modules/@parcel/graph/",\ "packageDependencies": [\ - ["@parcel/graph", "npm:3.0.0"],\ + ["@parcel/graph", "npm:3.2.0"],\ ["nullthrows", "npm:1.1.1"]\ ],\ "linkType": "HARD"\ @@ -1529,12 +1530,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/logger", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-logger-npm-2.10.0-41ac90e34c-52d0b5331d.zip/node_modules/@parcel/logger/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-logger-npm-2.12.0-7d2f85a906-be3fe9d9ea.zip/node_modules/@parcel/logger/",\ "packageDependencies": [\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/events", "npm:2.10.0"]\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/events", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }],\ @@ -1549,10 +1550,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/markdown-ansi", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-markdown-ansi-npm-2.10.0-4dd4da44f3-35e2d07ec8.zip/node_modules/@parcel/markdown-ansi/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-markdown-ansi-npm-2.12.0-6b0fe453df-850ee665d9.zip/node_modules/@parcel/markdown-ansi/",\ "packageDependencies": [\ - ["@parcel/markdown-ansi", "npm:2.10.0"],\ + ["@parcel/markdown-ansi", "npm:2.12.0"],\ ["chalk", "npm:4.1.2"]\ ],\ "linkType": "HARD"\ @@ -1567,27 +1568,27 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/namer-default", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-namer-default-npm-2.10.0-4b82db40fd-f2a32096d1.zip/node_modules/@parcel/namer-default/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-namer-default-npm-2.12.0-28980cfd47-dc92ec0945.zip/node_modules/@parcel/namer-default/",\ "packageDependencies": [\ - ["@parcel/namer-default", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/namer-default", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/node-resolver-core", [\ - ["npm:3.1.0", {\ - "packageLocation": "./.yarn/cache/@parcel-node-resolver-core-npm-3.1.0-9c9ff3ab8b-dcdd39bc6a.zip/node_modules/@parcel/node-resolver-core/",\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/@parcel-node-resolver-core-npm-3.3.0-53804df663-acc3721678.zip/node_modules/@parcel/node-resolver-core/",\ "packageDependencies": [\ - ["@parcel/node-resolver-core", "npm:3.1.0"],\ + ["@parcel/node-resolver-core", "npm:3.3.0"],\ ["@mischnic/json-sourcemap", "npm:0.1.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/fs", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/fs", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"],\ ["semver", "npm:7.5.4"]\ ],\ @@ -1595,14 +1596,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/optimizer-css", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-optimizer-css-npm-2.10.0-dbd5825b4e-ea15989512.zip/node_modules/@parcel/optimizer-css/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-optimizer-css-npm-2.12.0-f95bd4d060-abcdf58c29.zip/node_modules/@parcel/optimizer-css/",\ "packageDependencies": [\ - ["@parcel/optimizer-css", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/optimizer-css", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["browserslist", "npm:4.20.3"],\ ["lightningcss", "npm:1.17.1"],\ ["nullthrows", "npm:1.1.1"]\ @@ -1611,12 +1612,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/optimizer-data-url", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-optimizer-data-url-npm-2.10.0-700cb5aab6-ec9530be83.zip/node_modules/@parcel/optimizer-data-url/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-optimizer-data-url-npm-2.12.0-dad3731170-0397293961.zip/node_modules/@parcel/optimizer-data-url/",\ "packageDependencies": [\ - ["@parcel/optimizer-data-url", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/optimizer-data-url", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["isbinaryfile", "npm:4.0.10"],\ ["mime", "npm:2.6.0"]\ ],\ @@ -1624,12 +1625,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/optimizer-htmlnano", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-optimizer-htmlnano-npm-2.10.0-ee0243765c-1f6de13022.zip/node_modules/@parcel/optimizer-htmlnano/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-optimizer-htmlnano-npm-2.12.0-cdd2835c12-64e571f56f.zip/node_modules/@parcel/optimizer-htmlnano/",\ "packageDependencies": [\ - ["@parcel/optimizer-htmlnano", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["htmlnano", "virtual:ee0243765cbdf501388f259b4f1148af5bb4df5c2fa392d4cf1f1d61d3475a9c15a5729ae4be6dd2e258041e618368d112e36aa7b208b01a51861aaaf92fa944#npm:2.0.2"],\ + ["@parcel/optimizer-htmlnano", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["htmlnano", "virtual:cdd2835c1202e86fad55b2266578ff3755267672440481af37bdfff670fd205f561469a10385c20d1ff403af7fad49006bc71ffff21d12592a8ebd0c8be79c0c#npm:2.0.2"],\ ["nullthrows", "npm:1.1.1"],\ ["posthtml", "npm:0.16.6"],\ ["svgo", "npm:2.8.0"]\ @@ -1638,23 +1639,23 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/optimizer-image", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-optimizer-image-npm-2.10.0-a581b60cbd-94d5db2837.zip/node_modules/@parcel/optimizer-image/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-optimizer-image-npm-2.12.0-4cbc56f72d-7d28379bf1.zip/node_modules/@parcel/optimizer-image/",\ "packageDependencies": [\ - ["@parcel/optimizer-image", "npm:2.10.0"]\ + ["@parcel/optimizer-image", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-optimizer-image-virtual-9b7a1cafe7/0/cache/@parcel-optimizer-image-npm-2.10.0-a581b60cbd-94d5db2837.zip/node_modules/@parcel/optimizer-image/",\ - "packageDependencies": [\ - ["@parcel/optimizer-image", "virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/workers", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ + ["virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-optimizer-image-virtual-8c3b1760b5/0/cache/@parcel-optimizer-image-npm-2.12.0-4cbc56f72d-7d28379bf1.zip/node_modules/@parcel/optimizer-image/",\ + "packageDependencies": [\ + ["@parcel/optimizer-image", "virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/workers", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ ["@types/parcel__core", null]\ ],\ "packagePeers": [\ @@ -1665,38 +1666,38 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/optimizer-svgo", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-optimizer-svgo-npm-2.10.0-154d938969-7201c63222.zip/node_modules/@parcel/optimizer-svgo/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-optimizer-svgo-npm-2.12.0-08c0f1b17f-d3a4d2de9f.zip/node_modules/@parcel/optimizer-svgo/",\ "packageDependencies": [\ - ["@parcel/optimizer-svgo", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/optimizer-svgo", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["svgo", "npm:2.8.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/optimizer-swc", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-optimizer-swc-npm-2.10.0-caf3bb9c02-1fe68ee6ff.zip/node_modules/@parcel/optimizer-swc/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-optimizer-swc-npm-2.12.0-fb535e4283-0b7fdf3df1.zip/node_modules/@parcel/optimizer-swc/",\ "packageDependencies": [\ - ["@parcel/optimizer-swc", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/optimizer-swc", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@swc/core", "virtual:caf3bb9c02ae9f768ff8cb8f830dcff0d7f38e60f1817c3f155faf0af46cd208a17e673fb908c23a477f907e553fbf9eef21af5f078ed79b4c34aca3fefc5224#npm:1.3.62"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@swc/core", "virtual:5f8211ac5fe0096c8679c8fc747f0917af84ce168460ce1b592cb42613ababf55139691f5b329cd10e1e2b99af39861401c7b9633ed396447c506b02a80144b0#npm:1.3.62"],\ ["nullthrows", "npm:1.1.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/package-manager", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-package-manager-npm-2.10.0-4f4a39adee-7c4a95d9df.zip/node_modules/@parcel/package-manager/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-package-manager-npm-2.12.0-fc90aacf70-a517e9efe1.zip/node_modules/@parcel/package-manager/",\ "packageDependencies": [\ - ["@parcel/package-manager", "npm:2.10.0"]\ + ["@parcel/package-manager", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ @@ -1707,38 +1708,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-package-manager-virtual-2dbcea4ef2/0/cache/@parcel-package-manager-npm-2.10.0-4f4a39adee-7c4a95d9df.zip/node_modules/@parcel/package-manager/",\ - "packageDependencies": [\ - ["@parcel/package-manager", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/core", "npm:2.6.2"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/fs", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/node-resolver-core", "npm:3.1.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/workers", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@types/parcel__core", null],\ - ["semver", "npm:7.5.4"]\ - ],\ - "packagePeers": [\ - "@types/parcel__core"\ - ],\ - "linkType": "HARD"\ - }],\ - ["virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-package-manager-virtual-07edc7e62f/0/cache/@parcel-package-manager-npm-2.10.0-4f4a39adee-7c4a95d9df.zip/node_modules/@parcel/package-manager/",\ - "packageDependencies": [\ - ["@parcel/package-manager", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/fs", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/node-resolver-core", "npm:3.1.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/workers", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ + ["virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-package-manager-virtual-8612c9adea/0/cache/@parcel-package-manager-npm-2.12.0-fc90aacf70-a517e9efe1.zip/node_modules/@parcel/package-manager/",\ + "packageDependencies": [\ + ["@parcel/package-manager", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/fs", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/node-resolver-core", "npm:3.3.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/workers", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@swc/core", "virtual:5f8211ac5fe0096c8679c8fc747f0917af84ce168460ce1b592cb42613ababf55139691f5b329cd10e1e2b99af39861401c7b9633ed396447c506b02a80144b0#npm:1.3.62"],\ ["@types/parcel__core", null],\ ["semver", "npm:7.5.4"]\ ],\ @@ -1766,30 +1748,52 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "@types/parcel__core"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-package-manager-virtual-5f8211ac5f/0/cache/@parcel-package-manager-npm-2.12.0-fc90aacf70-a517e9efe1.zip/node_modules/@parcel/package-manager/",\ + "packageDependencies": [\ + ["@parcel/package-manager", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/core", "npm:2.6.2"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/fs", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/node-resolver-core", "npm:3.3.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/workers", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@swc/core", "virtual:5f8211ac5fe0096c8679c8fc747f0917af84ce168460ce1b592cb42613ababf55139691f5b329cd10e1e2b99af39861401c7b9633ed396447c506b02a80144b0#npm:1.3.62"],\ + ["@types/parcel__core", null],\ + ["semver", "npm:7.5.4"]\ + ],\ + "packagePeers": [\ + "@types/parcel__core"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@parcel/packager-css", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-packager-css-npm-2.10.0-cb31a968a8-11bf4cae4c.zip/node_modules/@parcel/packager-css/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-packager-css-npm-2.12.0-b1c27a8323-684aaa1d85.zip/node_modules/@parcel/packager-css/",\ "packageDependencies": [\ - ["@parcel/packager-css", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/packager-css", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["lightningcss", "npm:1.17.1"],\ ["nullthrows", "npm:1.1.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/packager-html", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-packager-html-npm-2.10.0-d6f71e7e36-8dfd86e7d6.zip/node_modules/@parcel/packager-html/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-packager-html-npm-2.12.0-ad361b1265-ee558ad616.zip/node_modules/@parcel/packager-html/",\ "packageDependencies": [\ - ["@parcel/packager-html", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/packager-html", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"],\ ["posthtml", "npm:0.16.6"]\ ],\ @@ -1797,16 +1801,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/packager-js", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-packager-js-npm-2.10.0-f84ec4cc7b-9b62598864.zip/node_modules/@parcel/packager-js/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-packager-js-npm-2.12.0-093e3200cd-2189b7ff15.zip/node_modules/@parcel/packager-js/",\ "packageDependencies": [\ - ["@parcel/packager-js", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ + ["@parcel/packager-js", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["globals", "npm:13.15.0"],\ ["nullthrows", "npm:1.1.1"]\ ],\ @@ -1814,44 +1818,44 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/packager-raw", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-packager-raw-npm-2.10.0-01ef1b8e3e-492fe07ae5.zip/node_modules/@parcel/packager-raw/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-packager-raw-npm-2.12.0-b7f15635f8-39ce2fc7ae.zip/node_modules/@parcel/packager-raw/",\ "packageDependencies": [\ - ["@parcel/packager-raw", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"]\ + ["@parcel/packager-raw", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/packager-svg", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-packager-svg-npm-2.10.0-22326715bd-f49d7f3b88.zip/node_modules/@parcel/packager-svg/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-packager-svg-npm-2.12.0-fa921ce522-436ac9ea39.zip/node_modules/@parcel/packager-svg/",\ "packageDependencies": [\ - ["@parcel/packager-svg", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/packager-svg", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["posthtml", "npm:0.16.6"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/packager-wasm", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-packager-wasm-npm-2.10.0-b1d2cd8f88-d9a13eb838.zip/node_modules/@parcel/packager-wasm/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-packager-wasm-npm-2.12.0-ec551a9e29-a10e1cd988.zip/node_modules/@parcel/packager-wasm/",\ "packageDependencies": [\ - ["@parcel/packager-wasm", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"]\ + ["@parcel/packager-wasm", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/plugin", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-plugin-npm-2.10.0-efbc58a209-e13ba6e7e5.zip/node_modules/@parcel/plugin/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-plugin-npm-2.12.0-947dec85d3-0b52f1dd06.zip/node_modules/@parcel/plugin/",\ "packageDependencies": [\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"]\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }],\ @@ -1865,25 +1869,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/profiler", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-profiler-npm-2.10.0-b1ba499bc1-78d545edb7.zip/node_modules/@parcel/profiler/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-profiler-npm-2.12.0-69720a23ab-b683b74e10.zip/node_modules/@parcel/profiler/",\ "packageDependencies": [\ - ["@parcel/profiler", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/events", "npm:2.10.0"],\ + ["@parcel/profiler", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/events", "npm:2.12.0"],\ ["chrome-trace-event", "npm:1.0.3"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/reporter-cli", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-reporter-cli-npm-2.10.0-083fc2f2d6-0137a91e45.zip/node_modules/@parcel/reporter-cli/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-reporter-cli-npm-2.12.0-b3e4c5fe19-8cc524fa15.zip/node_modules/@parcel/reporter-cli/",\ "packageDependencies": [\ - ["@parcel/reporter-cli", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/reporter-cli", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["chalk", "npm:4.1.2"],\ ["term-size", "npm:2.2.1"]\ ],\ @@ -1891,23 +1895,23 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/reporter-dev-server", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-reporter-dev-server-npm-2.10.0-2f19cb846e-e72fd6ec09.zip/node_modules/@parcel/reporter-dev-server/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-reporter-dev-server-npm-2.12.0-aed1d2c68c-43957b4656.zip/node_modules/@parcel/reporter-dev-server/",\ "packageDependencies": [\ - ["@parcel/reporter-dev-server", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"]\ + ["@parcel/reporter-dev-server", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/reporter-tracer", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-reporter-tracer-npm-2.10.0-184a89e262-0f8249b998.zip/node_modules/@parcel/reporter-tracer/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-reporter-tracer-npm-2.12.0-5cec9ab2d5-24cddacd19.zip/node_modules/@parcel/reporter-tracer/",\ "packageDependencies": [\ - ["@parcel/reporter-tracer", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/reporter-tracer", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["chrome-trace-event", "npm:1.0.3"],\ ["nullthrows", "npm:1.1.1"]\ ],\ @@ -1915,47 +1919,47 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/resolver-default", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-resolver-default-npm-2.10.0-ca49f01a75-c82e2d3c4b.zip/node_modules/@parcel/resolver-default/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-resolver-default-npm-2.12.0-8da790891c-f3652eea09.zip/node_modules/@parcel/resolver-default/",\ "packageDependencies": [\ - ["@parcel/resolver-default", "npm:2.10.0"],\ - ["@parcel/node-resolver-core", "npm:3.1.0"],\ - ["@parcel/plugin", "npm:2.10.0"]\ + ["@parcel/resolver-default", "npm:2.12.0"],\ + ["@parcel/node-resolver-core", "npm:3.3.0"],\ + ["@parcel/plugin", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/runtime-browser-hmr", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-runtime-browser-hmr-npm-2.10.0-c6b7773a09-12928462c8.zip/node_modules/@parcel/runtime-browser-hmr/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-runtime-browser-hmr-npm-2.12.0-6f0da66673-bbba57ecee.zip/node_modules/@parcel/runtime-browser-hmr/",\ "packageDependencies": [\ - ["@parcel/runtime-browser-hmr", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"]\ + ["@parcel/runtime-browser-hmr", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/runtime-js", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-runtime-js-npm-2.10.0-6b4cf1576c-3bbd64c5b9.zip/node_modules/@parcel/runtime-js/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-runtime-js-npm-2.12.0-e21acc0f42-6afa3e7eb2.zip/node_modules/@parcel/runtime-js/",\ "packageDependencies": [\ - ["@parcel/runtime-js", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/runtime-js", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/runtime-react-refresh", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-runtime-react-refresh-npm-2.10.0-b1f6c62bdf-dc567474a1.zip/node_modules/@parcel/runtime-react-refresh/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-runtime-react-refresh-npm-2.12.0-2b09615691-41aee9a874.zip/node_modules/@parcel/runtime-react-refresh/",\ "packageDependencies": [\ - ["@parcel/runtime-react-refresh", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/runtime-react-refresh", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["react-error-overlay", "npm:6.0.9"],\ ["react-refresh", "npm:0.9.0"]\ ],\ @@ -1963,22 +1967,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/runtime-service-worker", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-runtime-service-worker-npm-2.10.0-3ca99a5366-d0bfd113b9.zip/node_modules/@parcel/runtime-service-worker/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-runtime-service-worker-npm-2.12.0-7d227ff0bf-c71246428e.zip/node_modules/@parcel/runtime-service-worker/",\ "packageDependencies": [\ - ["@parcel/runtime-service-worker", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/runtime-service-worker", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/rust", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/unplugged/@parcel-rust-npm-2.10.0-99038406b0/node_modules/@parcel/rust/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/unplugged/@parcel-rust-npm-2.12.0-0cf943f3e5/node_modules/@parcel/rust/",\ "packageDependencies": [\ - ["@parcel/rust", "npm:2.10.0"]\ + ["@parcel/rust", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ @@ -2002,14 +2006,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-babel", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-babel-npm-2.10.0-fb74ad8c73-fd64092c9c.zip/node_modules/@parcel/transformer-babel/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-babel-npm-2.12.0-953de52432-b8c457c0be.zip/node_modules/@parcel/transformer-babel/",\ "packageDependencies": [\ - ["@parcel/transformer-babel", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/transformer-babel", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["browserslist", "npm:4.20.3"],\ ["json5", "npm:2.2.1"],\ ["nullthrows", "npm:1.1.1"],\ @@ -2019,14 +2023,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-css", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-css-npm-2.10.0-4fc35c8005-acc26e9b3d.zip/node_modules/@parcel/transformer-css/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-css-npm-2.12.0-24ddc31ae3-3a6f16321d.zip/node_modules/@parcel/transformer-css/",\ "packageDependencies": [\ - ["@parcel/transformer-css", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/transformer-css", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["browserslist", "npm:4.20.3"],\ ["lightningcss", "npm:1.17.1"],\ ["nullthrows", "npm:1.1.1"]\ @@ -2035,13 +2039,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-html", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-html-npm-2.10.0-b6d2228044-f28e0d3606.zip/node_modules/@parcel/transformer-html/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-html-npm-2.12.0-be2b9ee40c-7fcfac62ca.zip/node_modules/@parcel/transformer-html/",\ "packageDependencies": [\ - ["@parcel/transformer-html", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ + ["@parcel/transformer-html", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"],\ ["posthtml", "npm:0.16.6"],\ ["posthtml-parser", "npm:0.10.2"],\ @@ -2053,21 +2057,21 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-image", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-image-npm-2.10.0-e63bd526ed-61a47d7d8e.zip/node_modules/@parcel/transformer-image/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-image-npm-2.12.0-53f04e21c0-0a1581eacc.zip/node_modules/@parcel/transformer-image/",\ "packageDependencies": [\ - ["@parcel/transformer-image", "npm:2.10.0"]\ + ["@parcel/transformer-image", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-transformer-image-virtual-3e73171071/0/cache/@parcel-transformer-image-npm-2.10.0-e63bd526ed-61a47d7d8e.zip/node_modules/@parcel/transformer-image/",\ - "packageDependencies": [\ - ["@parcel/transformer-image", "virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/workers", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ + ["virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-transformer-image-virtual-acc9c20c9c/0/cache/@parcel-transformer-image-npm-2.12.0-53f04e21c0-0a1581eacc.zip/node_modules/@parcel/transformer-image/",\ + "packageDependencies": [\ + ["@parcel/transformer-image", "virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/workers", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ ["@types/parcel__core", null],\ ["nullthrows", "npm:1.1.1"]\ ],\ @@ -2079,34 +2083,34 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-inline-string", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-inline-string-npm-2.10.0-44c9b349db-618c919108.zip/node_modules/@parcel/transformer-inline-string/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-inline-string-npm-2.12.0-a33f10bafa-5f63c08695.zip/node_modules/@parcel/transformer-inline-string/",\ "packageDependencies": [\ - ["@parcel/transformer-inline-string", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"]\ + ["@parcel/transformer-inline-string", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/transformer-js", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-js-npm-2.10.0-132e460926-e9944ce77c.zip/node_modules/@parcel/transformer-js/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-js-npm-2.12.0-404d54db18-b9fe4c887b.zip/node_modules/@parcel/transformer-js/",\ "packageDependencies": [\ - ["@parcel/transformer-js", "npm:2.10.0"]\ + ["@parcel/transformer-js", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-transformer-js-virtual-aab7779f34/0/cache/@parcel-transformer-js-npm-2.10.0-132e460926-e9944ce77c.zip/node_modules/@parcel/transformer-js/",\ - "packageDependencies": [\ - ["@parcel/transformer-js", "virtual:61dcbb3314ed0db4613bbccf7f920606ba09571991ce6dbf378d0f819338fde0afdb3e35306b4f7d24155236814007855a39ab7d05afaeadead37df42309ed7e#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ + ["virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-transformer-js-virtual-567f83ac24/0/cache/@parcel-transformer-js-npm-2.12.0-404d54db18-b9fe4c887b.zip/node_modules/@parcel/transformer-js/",\ + "packageDependencies": [\ + ["@parcel/transformer-js", "virtual:284acdc258f2328e304855ff98dec9e5e8952a2bd7797a2e11c082f6cad2e0d3068e07fb498d46b810d8efae36becee510ac53186a75e438e809dc472f832ab2#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/workers", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/workers", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ ["@swc/helpers", "npm:0.5.1"],\ ["@types/parcel__core", null],\ ["browserslist", "npm:4.20.3"],\ @@ -2122,25 +2126,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-json", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-json-npm-2.10.0-5525143f86-9c7aceb8e6.zip/node_modules/@parcel/transformer-json/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-json-npm-2.12.0-652d8d99d2-a711cb65a8.zip/node_modules/@parcel/transformer-json/",\ "packageDependencies": [\ - ["@parcel/transformer-json", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/transformer-json", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["json5", "npm:2.2.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/transformer-postcss", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-postcss-npm-2.10.0-c1f60c708a-2e524bd513.zip/node_modules/@parcel/transformer-postcss/",\ - "packageDependencies": [\ - ["@parcel/transformer-postcss", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-postcss-npm-2.12.0-f0cfb95fac-b210044a7f.zip/node_modules/@parcel/transformer-postcss/",\ + "packageDependencies": [\ + ["@parcel/transformer-postcss", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["clone", "npm:2.1.2"],\ ["nullthrows", "npm:1.1.1"],\ ["postcss-value-parser", "npm:4.2.0"],\ @@ -2150,12 +2154,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-posthtml", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-posthtml-npm-2.10.0-31d54ed3f0-7de343f0f9.zip/node_modules/@parcel/transformer-posthtml/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-posthtml-npm-2.12.0-41c570db12-b62582ae7e.zip/node_modules/@parcel/transformer-posthtml/",\ "packageDependencies": [\ - ["@parcel/transformer-posthtml", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/transformer-posthtml", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"],\ ["posthtml", "npm:0.16.6"],\ ["posthtml-parser", "npm:0.10.2"],\ @@ -2166,33 +2170,33 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-raw", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-raw-npm-2.10.0-d7cd50f767-c7b1b9c6f7.zip/node_modules/@parcel/transformer-raw/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-raw-npm-2.12.0-bd2cb66ddf-de6681e2e7.zip/node_modules/@parcel/transformer-raw/",\ "packageDependencies": [\ - ["@parcel/transformer-raw", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"]\ + ["@parcel/transformer-raw", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/transformer-react-refresh-wrap", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.10.0-4c3ddcc095-fc3163bcb0.zip/node_modules/@parcel/transformer-react-refresh-wrap/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.12.0-59ed68910f-9aba8c1ab0.zip/node_modules/@parcel/transformer-react-refresh-wrap/",\ "packageDependencies": [\ - ["@parcel/transformer-react-refresh-wrap", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["@parcel/transformer-react-refresh-wrap", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["react-refresh", "npm:0.9.0"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@parcel/transformer-sass", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-sass-npm-2.10.0-6c5f188bcc-2d697077ac.zip/node_modules/@parcel/transformer-sass/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-sass-npm-2.12.0-ef787eef35-ce6b4d329b.zip/node_modules/@parcel/transformer-sass/",\ "packageDependencies": [\ - ["@parcel/transformer-sass", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ + ["@parcel/transformer-sass", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ ["sass", "npm:1.52.1"]\ ],\ @@ -2200,13 +2204,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/transformer-svg", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-transformer-svg-npm-2.10.0-881c72cd1f-d5f55f6eee.zip/node_modules/@parcel/transformer-svg/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-transformer-svg-npm-2.12.0-f41b181676-92b7c65894.zip/node_modules/@parcel/transformer-svg/",\ "packageDependencies": [\ - ["@parcel/transformer-svg", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/plugin", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ + ["@parcel/transformer-svg", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/plugin", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ ["nullthrows", "npm:1.1.1"],\ ["posthtml", "npm:0.16.6"],\ ["posthtml-parser", "npm:0.10.2"],\ @@ -2217,16 +2221,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/types", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-types-npm-2.10.0-270e786ba1-387aa07902.zip/node_modules/@parcel/types/",\ - "packageDependencies": [\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/cache", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/fs", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/package-manager", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-types-npm-2.12.0-ffe47febbf-250f95580c.zip/node_modules/@parcel/types/",\ + "packageDependencies": [\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/cache", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/fs", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/package-manager", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ - ["@parcel/workers", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ + ["@parcel/workers", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ ["utility-types", "npm:3.10.0"]\ ],\ "linkType": "HARD"\ @@ -2247,15 +2251,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/utils", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-utils-npm-2.10.0-1f25fbc366-9f4953ff9a.zip/node_modules/@parcel/utils/",\ - "packageDependencies": [\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@parcel/codeframe", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/markdown-ansi", "npm:2.10.0"],\ - ["@parcel/rust", "npm:2.10.0"],\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-utils-npm-2.12.0-d8a9a48a66-ba80a60fed.zip/node_modules/@parcel/utils/",\ + "packageDependencies": [\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@parcel/codeframe", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/markdown-ansi", "npm:2.12.0"],\ + ["@parcel/rust", "npm:2.12.0"],\ ["@parcel/source-map", "npm:2.1.1"],\ ["chalk", "npm:4.1.2"],\ ["nullthrows", "npm:1.1.1"]\ @@ -2300,10 +2304,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@parcel/workers", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/@parcel-workers-npm-2.10.0-7f8aa5ad5a-e8b1701b53.zip/node_modules/@parcel/workers/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/@parcel-workers-npm-2.12.0-3ddd4664bc-e19c3c0a66.zip/node_modules/@parcel/workers/",\ "packageDependencies": [\ - ["@parcel/workers", "npm:2.10.0"]\ + ["@parcel/workers", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ @@ -2314,34 +2318,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-workers-virtual-2fd2d815f8/0/cache/@parcel-workers-npm-2.10.0-7f8aa5ad5a-e8b1701b53.zip/node_modules/@parcel/workers/",\ - "packageDependencies": [\ - ["@parcel/workers", "virtual:270e786ba124f493b75e4cd9a08f7491010f97327e0fcf0c93872db7e85ab335c548e71e39c548e3ecd0ddd319719697b172c5c43cd0b75c1948a8e82873b962#npm:2.10.0"],\ - ["@parcel/core", "npm:2.6.2"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/profiler", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ - ["@types/parcel__core", null],\ - ["nullthrows", "npm:1.1.1"]\ - ],\ - "packagePeers": [\ - "@types/parcel__core"\ - ],\ - "linkType": "HARD"\ - }],\ - ["virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/@parcel-workers-virtual-5aa58b2681/0/cache/@parcel-workers-npm-2.10.0-7f8aa5ad5a-e8b1701b53.zip/node_modules/@parcel/workers/",\ - "packageDependencies": [\ - ["@parcel/workers", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/profiler", "npm:2.10.0"],\ - ["@parcel/types", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-workers-virtual-fbd6240557/0/cache/@parcel-workers-npm-2.12.0-3ddd4664bc-e19c3c0a66.zip/node_modules/@parcel/workers/",\ + "packageDependencies": [\ + ["@parcel/workers", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/profiler", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["@types/parcel__core", null],\ ["nullthrows", "npm:1.1.1"]\ ],\ @@ -2368,6 +2354,24 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "@types/parcel__core"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/@parcel-workers-virtual-0f6ac1cb6e/0/cache/@parcel-workers-npm-2.12.0-3ddd4664bc-e19c3c0a66.zip/node_modules/@parcel/workers/",\ + "packageDependencies": [\ + ["@parcel/workers", "virtual:ffe47febbf7847f9b64454e506be514f3cbd8bbd1821ba64e8e762685b5100c3f7867a926c2aa7f5349f2a1370184e7d2f8f70428bcab9b21701f56d9632c378#npm:2.12.0"],\ + ["@parcel/core", "npm:2.6.2"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/profiler", "npm:2.12.0"],\ + ["@parcel/types", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ + ["@types/parcel__core", null],\ + ["nullthrows", "npm:1.1.1"]\ + ],\ + "packagePeers": [\ + "@types/parcel__core"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@pkgjs/parseargs", [\ @@ -2396,17 +2400,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@rollup/pluginutils", [\ - ["npm:5.0.5", {\ - "packageLocation": "./.yarn/cache/@rollup-pluginutils-npm-5.0.5-cfa8fafc53-dcd4d6e3cb.zip/node_modules/@rollup/pluginutils/",\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/@rollup-pluginutils-npm-5.1.0-6939820ef8-3cc5a6d914.zip/node_modules/@rollup/pluginutils/",\ "packageDependencies": [\ - ["@rollup/pluginutils", "npm:5.0.5"]\ + ["@rollup/pluginutils", "npm:5.1.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.0.5", {\ - "packageLocation": "./.yarn/__virtual__/@rollup-pluginutils-virtual-9173e115a7/0/cache/@rollup-pluginutils-npm-5.0.5-cfa8fafc53-dcd4d6e3cb.zip/node_modules/@rollup/pluginutils/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.1.0", {\ + "packageLocation": "./.yarn/__virtual__/@rollup-pluginutils-virtual-e968017249/0/cache/@rollup-pluginutils-npm-5.1.0-6939820ef8-3cc5a6d914.zip/node_modules/@rollup/pluginutils/",\ "packageDependencies": [\ - ["@rollup/pluginutils", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.0.5"],\ + ["@rollup/pluginutils", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.1.0"],\ ["@types/estree", "npm:1.0.0"],\ ["@types/rollup", null],\ ["estree-walker", "npm:2.0.2"],\ @@ -2421,17 +2425,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@sidvind/better-ajv-errors", [\ - ["npm:2.0.0", {\ - "packageLocation": "./.yarn/cache/@sidvind-better-ajv-errors-npm-2.0.0-3531bddef9-12b0d87855.zip/node_modules/@sidvind/better-ajv-errors/",\ + ["npm:2.1.3", {\ + "packageLocation": "./.yarn/cache/@sidvind-better-ajv-errors-npm-2.1.3-e3d1c524a8-949cb805a1.zip/node_modules/@sidvind/better-ajv-errors/",\ "packageDependencies": [\ - ["@sidvind/better-ajv-errors", "npm:2.0.0"]\ + ["@sidvind/better-ajv-errors", "npm:2.1.3"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:c83421120a0d81573c2202cf177f92bb37ea59966baf7759b6fa4e9a2982e6fe336614d87630b5be4c85c00ef51834cf2a040f710a51f15e792d3dd3e2377e0f#npm:2.0.0", {\ - "packageLocation": "./.yarn/__virtual__/@sidvind-better-ajv-errors-virtual-017003deb2/0/cache/@sidvind-better-ajv-errors-npm-2.0.0-3531bddef9-12b0d87855.zip/node_modules/@sidvind/better-ajv-errors/",\ + ["virtual:127f44ed2d4bdd83725c8820683da6ed922142e851761151ec4911dcca098a2d4d832b774a31e7abf98185d360ff83c999b319ea3314c86d38eca853072fb0d2#npm:2.1.3", {\ + "packageLocation": "./.yarn/__virtual__/@sidvind-better-ajv-errors-virtual-ae7b5eb579/0/cache/@sidvind-better-ajv-errors-npm-2.1.3-e3d1c524a8-949cb805a1.zip/node_modules/@sidvind/better-ajv-errors/",\ "packageDependencies": [\ - ["@sidvind/better-ajv-errors", "virtual:c83421120a0d81573c2202cf177f92bb37ea59966baf7759b6fa4e9a2982e6fe336614d87630b5be4c85c00ef51834cf2a040f710a51f15e792d3dd3e2377e0f#npm:2.0.0"],\ + ["@sidvind/better-ajv-errors", "virtual:127f44ed2d4bdd83725c8820683da6ed922142e851761151ec4911dcca098a2d4d832b774a31e7abf98185d360ff83c999b319ea3314c86d38eca853072fb0d2#npm:2.1.3"],\ ["@babel/code-frame", "npm:7.16.7"],\ ["@types/ajv", null],\ ["ajv", "npm:8.11.0"],\ @@ -2446,16 +2450,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ]],\ ["@swc/core", [\ ["npm:1.3.62", {\ - "packageLocation": "./.yarn/unplugged/@swc-core-virtual-fa54a417e9/node_modules/@swc/core/",\ + "packageLocation": "./.yarn/unplugged/@swc-core-virtual-8fda1c3f9b/node_modules/@swc/core/",\ "packageDependencies": [\ ["@swc/core", "npm:1.3.62"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:caf3bb9c02ae9f768ff8cb8f830dcff0d7f38e60f1817c3f155faf0af46cd208a17e673fb908c23a477f907e553fbf9eef21af5f078ed79b4c34aca3fefc5224#npm:1.3.62", {\ - "packageLocation": "./.yarn/unplugged/@swc-core-virtual-fa54a417e9/node_modules/@swc/core/",\ + ["virtual:5f8211ac5fe0096c8679c8fc747f0917af84ce168460ce1b592cb42613ababf55139691f5b329cd10e1e2b99af39861401c7b9633ed396447c506b02a80144b0#npm:1.3.62", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-virtual-8fda1c3f9b/node_modules/@swc/core/",\ "packageDependencies": [\ - ["@swc/core", "virtual:caf3bb9c02ae9f768ff8cb8f830dcff0d7f38e60f1817c3f155faf0af46cd208a17e673fb908c23a477f907e553fbf9eef21af5f078ed79b4c34aca3fefc5224#npm:1.3.62"],\ + ["@swc/core", "virtual:5f8211ac5fe0096c8679c8fc747f0917af84ce168460ce1b592cb42613ababf55139691f5b329cd10e1e2b99af39861401c7b9633ed396447c506b02a80144b0#npm:1.3.62"],\ ["@swc/core-darwin-arm64", "npm:1.3.62"],\ ["@swc/core-darwin-x64", "npm:1.3.62"],\ ["@swc/core-linux-arm-gnueabihf", "npm:1.3.62"],\ @@ -2693,21 +2697,21 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@vitejs/plugin-vue", [\ - ["npm:4.4.0", {\ - "packageLocation": "./.yarn/cache/@vitejs-plugin-vue-npm-4.4.0-c33d65c6f6-37b6987951.zip/node_modules/@vitejs/plugin-vue/",\ + ["npm:4.6.2", {\ + "packageLocation": "./.yarn/cache/@vitejs-plugin-vue-npm-4.6.2-d7ace53203-01bc4ed643.zip/node_modules/@vitejs/plugin-vue/",\ "packageDependencies": [\ - ["@vitejs/plugin-vue", "npm:4.4.0"]\ + ["@vitejs/plugin-vue", "npm:4.6.2"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.4.0", {\ - "packageLocation": "./.yarn/__virtual__/@vitejs-plugin-vue-virtual-6c0c604c2e/0/cache/@vitejs-plugin-vue-npm-4.4.0-c33d65c6f6-37b6987951.zip/node_modules/@vitejs/plugin-vue/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.6.2", {\ + "packageLocation": "./.yarn/__virtual__/@vitejs-plugin-vue-virtual-090b584a9c/0/cache/@vitejs-plugin-vue-npm-4.6.2-d7ace53203-01bc4ed643.zip/node_modules/@vitejs/plugin-vue/",\ "packageDependencies": [\ - ["@vitejs/plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.4.0"],\ + ["@vitejs/plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.6.2"],\ ["@types/vite", null],\ ["@types/vue", null],\ - ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.0"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.2"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vite",\ @@ -2718,13 +2722,56 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@volar/language-core", [\ + ["npm:2.1.4", {\ + "packageLocation": "./.yarn/cache/@volar-language-core-npm-2.1.4-18ee1a037d-7430f65143.zip/node_modules/@volar/language-core/",\ + "packageDependencies": [\ + ["@volar/language-core", "npm:2.1.4"],\ + ["@volar/source-map", "npm:2.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@volar/language-service", [\ + ["npm:2.1.4", {\ + "packageLocation": "./.yarn/cache/@volar-language-service-npm-2.1.4-2d34cb628f-06cdcfacf0.zip/node_modules/@volar/language-service/",\ + "packageDependencies": [\ + ["@volar/language-service", "npm:2.1.4"],\ + ["@volar/language-core", "npm:2.1.4"],\ + ["vscode-languageserver-protocol", "npm:3.17.5"],\ + ["vscode-languageserver-textdocument", "npm:1.0.11"],\ + ["vscode-uri", "npm:3.0.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@volar/source-map", [\ + ["npm:2.1.4", {\ + "packageLocation": "./.yarn/cache/@volar-source-map-npm-2.1.4-5963b1701f-e2f65bcfd6.zip/node_modules/@volar/source-map/",\ + "packageDependencies": [\ + ["@volar/source-map", "npm:2.1.4"],\ + ["muggle-string", "npm:0.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vscode/l10n", [\ + ["npm:0.0.18", {\ + "packageLocation": "./.yarn/cache/@vscode-l10n-npm-0.0.18-8a12efe4b5-c33876cebd.zip/node_modules/@vscode/l10n/",\ + "packageDependencies": [\ + ["@vscode/l10n", "npm:0.0.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@vue/compiler-core", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-compiler-core-npm-3.3.7-584a90831e-94ac56a5a8.zip/node_modules/@vue/compiler-core/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-core-npm-3.4.21-ec7f24d7f5-0d6b7732bc.zip/node_modules/@vue/compiler-core/",\ "packageDependencies": [\ - ["@vue/compiler-core", "npm:3.3.7"],\ - ["@babel/parser", "npm:7.23.0"],\ - ["@vue/shared", "npm:3.3.7"],\ + ["@vue/compiler-core", "npm:3.4.21"],\ + ["@babel/parser", "npm:7.23.9"],\ + ["@vue/shared", "npm:3.4.21"],\ + ["entities", "npm:4.5.0"],\ ["estree-walker", "npm:2.0.2"],\ ["source-map-js", "npm:1.0.2"]\ ],\ @@ -2732,42 +2779,41 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@vue/compiler-dom", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-compiler-dom-npm-3.3.7-53a75677b1-d54c49fd82.zip/node_modules/@vue/compiler-dom/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-dom-npm-3.4.21-3d49f99020-f53e4f4e0a.zip/node_modules/@vue/compiler-dom/",\ "packageDependencies": [\ - ["@vue/compiler-dom", "npm:3.3.7"],\ - ["@vue/compiler-core", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"]\ + ["@vue/compiler-dom", "npm:3.4.21"],\ + ["@vue/compiler-core", "npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@vue/compiler-sfc", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-compiler-sfc-npm-3.3.7-57757e7ccc-593c0b00f3.zip/node_modules/@vue/compiler-sfc/",\ - "packageDependencies": [\ - ["@vue/compiler-sfc", "npm:3.3.7"],\ - ["@babel/parser", "npm:7.23.0"],\ - ["@vue/compiler-core", "npm:3.3.7"],\ - ["@vue/compiler-dom", "npm:3.3.7"],\ - ["@vue/compiler-ssr", "npm:3.3.7"],\ - ["@vue/reactivity-transform", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"],\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-sfc-npm-3.4.21-c2b76ee1ff-226dc404be.zip/node_modules/@vue/compiler-sfc/",\ + "packageDependencies": [\ + ["@vue/compiler-sfc", "npm:3.4.21"],\ + ["@babel/parser", "npm:7.23.9"],\ + ["@vue/compiler-core", "npm:3.4.21"],\ + ["@vue/compiler-dom", "npm:3.4.21"],\ + ["@vue/compiler-ssr", "npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"],\ ["estree-walker", "npm:2.0.2"],\ - ["magic-string", "npm:0.30.5"],\ - ["postcss", "npm:8.4.31"],\ + ["magic-string", "npm:0.30.7"],\ + ["postcss", "npm:8.4.35"],\ ["source-map-js", "npm:1.0.2"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@vue/compiler-ssr", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-compiler-ssr-npm-3.3.7-61efc0860e-42f8ddc9ff.zip/node_modules/@vue/compiler-ssr/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-ssr-npm-3.4.21-e6f043341e-c510bee68b.zip/node_modules/@vue/compiler-ssr/",\ "packageDependencies": [\ - ["@vue/compiler-ssr", "npm:3.3.7"],\ - ["@vue/compiler-dom", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"]\ + ["@vue/compiler-ssr", "npm:3.4.21"],\ + ["@vue/compiler-dom", "npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"]\ ],\ "linkType": "HARD"\ }]\ @@ -2779,71 +2825,75 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@vue/devtools-api", "npm:6.5.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:6.6.1", {\ + "packageLocation": "./.yarn/cache/@vue-devtools-api-npm-6.6.1-ef3c82703e-cf12b5ebcc.zip/node_modules/@vue/devtools-api/",\ + "packageDependencies": [\ + ["@vue/devtools-api", "npm:6.6.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ - ["@vue/reactivity", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-reactivity-npm-3.3.7-0bf3bc7094-16b629c997.zip/node_modules/@vue/reactivity/",\ + ["@vue/language-plugin-pug", [\ + ["npm:2.0.7", {\ + "packageLocation": "./.yarn/cache/@vue-language-plugin-pug-npm-2.0.7-547300c7e0-11cc96eb5f.zip/node_modules/@vue/language-plugin-pug/",\ "packageDependencies": [\ - ["@vue/reactivity", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"]\ + ["@vue/language-plugin-pug", "npm:2.0.7"],\ + ["@volar/source-map", "npm:2.1.4"],\ + ["volar-service-pug", "npm:0.0.34"]\ ],\ "linkType": "HARD"\ }]\ ]],\ - ["@vue/reactivity-transform", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-reactivity-transform-npm-3.3.7-7c404f0363-f88d39c8a4.zip/node_modules/@vue/reactivity-transform/",\ + ["@vue/reactivity", [\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-reactivity-npm-3.4.21-fd3e254d08-79c7ebe3ec.zip/node_modules/@vue/reactivity/",\ "packageDependencies": [\ - ["@vue/reactivity-transform", "npm:3.3.7"],\ - ["@babel/parser", "npm:7.23.0"],\ - ["@vue/compiler-core", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"],\ - ["estree-walker", "npm:2.0.2"],\ - ["magic-string", "npm:0.30.5"]\ + ["@vue/reactivity", "npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@vue/runtime-core", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-runtime-core-npm-3.3.7-61d8aab53a-3fd38c6831.zip/node_modules/@vue/runtime-core/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-runtime-core-npm-3.4.21-7bf985040b-4eb9b5d91f.zip/node_modules/@vue/runtime-core/",\ "packageDependencies": [\ - ["@vue/runtime-core", "npm:3.3.7"],\ - ["@vue/reactivity", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"]\ + ["@vue/runtime-core", "npm:3.4.21"],\ + ["@vue/reactivity", "npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@vue/runtime-dom", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-runtime-dom-npm-3.3.7-07cf6cc840-9331ea0886.zip/node_modules/@vue/runtime-dom/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-runtime-dom-npm-3.4.21-40f99cf9a2-ebfdaa081f.zip/node_modules/@vue/runtime-dom/",\ "packageDependencies": [\ - ["@vue/runtime-dom", "npm:3.3.7"],\ - ["@vue/runtime-core", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"],\ - ["csstype", "npm:3.1.2"]\ + ["@vue/runtime-dom", "npm:3.4.21"],\ + ["@vue/runtime-core", "npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"],\ + ["csstype", "npm:3.1.3"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["@vue/server-renderer", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-server-renderer-npm-3.3.7-a66f8ec338-c2e673d6f7.zip/node_modules/@vue/server-renderer/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-server-renderer-npm-3.4.21-bf6b2daebb-faa3dc4876.zip/node_modules/@vue/server-renderer/",\ "packageDependencies": [\ - ["@vue/server-renderer", "npm:3.3.7"]\ + ["@vue/server-renderer", "npm:3.4.21"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:3abdc07485f088dc7c94fb6016205a603cbf92506f0de3d8b0b3c64fb975ecc9ffddafd33901d1bbe205fa301d19a0b5580e4aae44cfc80bff10566fc4d7e20b#npm:3.3.7", {\ - "packageLocation": "./.yarn/__virtual__/@vue-server-renderer-virtual-3e99bf9267/0/cache/@vue-server-renderer-npm-3.3.7-a66f8ec338-c2e673d6f7.zip/node_modules/@vue/server-renderer/",\ + ["virtual:b79af6274dddda2b283f42be2b827e30c3e5389bce2938ee73bdb74ee9781811fc079c6836719e57940708d59b3beeb14d9e3c12f37f2d22582a53e6c32e4c97#npm:3.4.21", {\ + "packageLocation": "./.yarn/__virtual__/@vue-server-renderer-virtual-4c61378d94/0/cache/@vue-server-renderer-npm-3.4.21-bf6b2daebb-faa3dc4876.zip/node_modules/@vue/server-renderer/",\ "packageDependencies": [\ - ["@vue/server-renderer", "virtual:3abdc07485f088dc7c94fb6016205a603cbf92506f0de3d8b0b3c64fb975ecc9ffddafd33901d1bbe205fa301d19a0b5580e4aae44cfc80bff10566fc4d7e20b#npm:3.3.7"],\ + ["@vue/server-renderer", "virtual:b79af6274dddda2b283f42be2b827e30c3e5389bce2938ee73bdb74ee9781811fc079c6836719e57940708d59b3beeb14d9e3c12f37f2d22582a53e6c32e4c97#npm:3.4.21"],\ ["@types/vue", null],\ - ["@vue/compiler-ssr", "npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["@vue/compiler-ssr", "npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -2853,10 +2903,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@vue/shared", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/@vue-shared-npm-3.3.7-44832a6399-a6718f760b.zip/node_modules/@vue/shared/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/@vue-shared-npm-3.4.21-2aee4ae0bc-5f30a40891.zip/node_modules/@vue/shared/",\ "packageDependencies": [\ - ["@vue/shared", "npm:3.3.7"]\ + ["@vue/shared", "npm:3.4.21"]\ ],\ "linkType": "HARD"\ }]\ @@ -3247,10 +3297,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["npm:5.3.2", {\ - "packageLocation": "./.yarn/cache/bootstrap-npm-5.3.2-20b391b636-d5580b253d.zip/node_modules/bootstrap/",\ + ["npm:5.3.3", {\ + "packageLocation": "./.yarn/cache/bootstrap-npm-5.3.3-da08e2f0fe-537b68db30.zip/node_modules/bootstrap/",\ "packageDependencies": [\ - ["bootstrap", "npm:5.3.2"]\ + ["bootstrap", "npm:5.3.3"]\ ],\ "linkType": "SOFT"\ }],\ @@ -3267,10 +3317,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.2", {\ - "packageLocation": "./.yarn/__virtual__/bootstrap-virtual-b366fabcb3/0/cache/bootstrap-npm-5.3.2-20b391b636-d5580b253d.zip/node_modules/bootstrap/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.3", {\ + "packageLocation": "./.yarn/__virtual__/bootstrap-virtual-2c24090b13/0/cache/bootstrap-npm-5.3.3-da08e2f0fe-537b68db30.zip/node_modules/bootstrap/",\ "packageDependencies": [\ - ["bootstrap", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.2"],\ + ["bootstrap", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.3"],\ ["@popperjs/core", "npm:2.11.8"],\ ["@types/popperjs__core", null]\ ],\ @@ -3282,10 +3332,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["bootstrap-icons", [\ - ["npm:1.11.1", {\ - "packageLocation": "./.yarn/cache/bootstrap-icons-npm-1.11.1-9f55aea76a-d78ff24a83.zip/node_modules/bootstrap-icons/",\ + ["npm:1.11.3", {\ + "packageLocation": "./.yarn/cache/bootstrap-icons-npm-1.11.3-8d5387bef2-d5cdb90fe3.zip/node_modules/bootstrap-icons/",\ "packageDependencies": [\ - ["bootstrap-icons", "npm:1.11.1"]\ + ["bootstrap-icons", "npm:1.11.3"]\ ],\ "linkType": "HARD"\ }]\ @@ -3352,6 +3402,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["builtin-modules", [\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/builtin-modules-npm-3.3.0-db4f3d32de-db021755d7.zip/node_modules/builtin-modules/",\ + "packageDependencies": [\ + ["builtin-modules", "npm:3.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["builtins", [\ ["npm:5.0.1", {\ "packageLocation": "./.yarn/cache/builtins-npm-5.0.1-6d4820dd76-66d204657f.zip/node_modules/builtins/",\ @@ -3363,18 +3422,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["c8", [\ - ["npm:8.0.1", {\ - "packageLocation": "./.yarn/cache/c8-npm-8.0.1-6462c8130b-2c47531d21.zip/node_modules/c8/",\ + ["npm:9.1.0", {\ + "packageLocation": "./.yarn/cache/c8-npm-9.1.0-92c3d37f46-c5249bf9c3.zip/node_modules/c8/",\ "packageDependencies": [\ - ["c8", "npm:8.0.1"],\ + ["c8", "npm:9.1.0"],\ ["@bcoe/v8-coverage", "npm:0.2.3"],\ ["@istanbuljs/schema", "npm:0.1.3"],\ ["find-up", "npm:5.0.0"],\ - ["foreground-child", "npm:2.0.0"],\ + ["foreground-child", "npm:3.1.1"],\ ["istanbul-lib-coverage", "npm:3.2.0"],\ ["istanbul-lib-report", "npm:3.0.1"],\ ["istanbul-reports", "npm:3.1.6"],\ - ["rimraf", "npm:3.0.2"],\ ["test-exclude", "npm:6.0.0"],\ ["v8-to-istanbul", "npm:9.0.1"],\ ["yargs", "npm:17.7.2"],\ @@ -3448,10 +3506,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["npm:1.0.30001538", {\ - "packageLocation": "./.yarn/cache/caniuse-lite-npm-1.0.30001538-68bfe8259b-94c5d55757.zip/node_modules/caniuse-lite/",\ + ["npm:1.0.30001599", {\ + "packageLocation": "./.yarn/cache/caniuse-lite-npm-1.0.30001599-834cd4cb82-d7e619e2e7.zip/node_modules/caniuse-lite/",\ "packageDependencies": [\ - ["caniuse-lite", "npm:1.0.30001538"]\ + ["caniuse-lite", "npm:1.0.30001599"]\ ],\ "linkType": "HARD"\ }]\ @@ -3751,19 +3809,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["npm:3.1.2", {\ - "packageLocation": "./.yarn/cache/csstype-npm-3.1.2-cead7d99b2-e1a52e6c25.zip/node_modules/csstype/",\ + ["npm:3.1.3", {\ + "packageLocation": "./.yarn/cache/csstype-npm-3.1.3-e9a1c85013-8db785cc92.zip/node_modules/csstype/",\ "packageDependencies": [\ - ["csstype", "npm:3.1.2"]\ + ["csstype", "npm:3.1.3"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["d3", [\ - ["npm:7.8.5", {\ - "packageLocation": "./.yarn/cache/d3-npm-7.8.5-5db20a5616-e407e79731.zip/node_modules/d3/",\ + ["npm:7.9.0", {\ + "packageLocation": "./.yarn/cache/d3-npm-7.9.0-d293821ce6-1c0e9135f1.zip/node_modules/d3/",\ "packageDependencies": [\ - ["d3", "npm:7.8.5"],\ + ["d3", "npm:7.9.0"],\ ["d3-array", "npm:3.1.6"],\ ["d3-axis", "npm:3.0.0"],\ ["d3-brush", "npm:3.0.0"],\ @@ -4147,10 +4205,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:2.0.0", {\ - "packageLocation": "./.yarn/__virtual__/date-fns-tz-virtual-b19bed24ca/0/cache/date-fns-tz-npm-2.0.0-9b7996f292-a6553603a9.zip/node_modules/date-fns-tz/",\ + ["virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:2.0.0", {\ + "packageLocation": "./.yarn/__virtual__/date-fns-tz-virtual-6610d5adee/0/cache/date-fns-tz-npm-2.0.0-9b7996f292-a6553603a9.zip/node_modules/date-fns-tz/",\ "packageDependencies": [\ - ["date-fns-tz", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:2.0.0"],\ + ["date-fns-tz", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:2.0.0"],\ ["@types/date-fns", null],\ ["date-fns", "npm:2.30.0"]\ ],\ @@ -4500,6 +4558,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["entities", "npm:3.0.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/entities-npm-4.5.0-7cdb83b832-853f8ebd5b.zip/node_modules/entities/",\ + "packageDependencies": [\ + ["entities", "npm:4.5.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["env-paths", [\ @@ -4678,15 +4743,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["eslint", [\ - ["npm:8.52.0", {\ - "packageLocation": "./.yarn/cache/eslint-npm-8.52.0-e7f048a439-fd22d1e9bd.zip/node_modules/eslint/",\ + ["npm:8.57.0", {\ + "packageLocation": "./.yarn/cache/eslint-npm-8.57.0-4286e12a3a-3a48d7ff85.zip/node_modules/eslint/",\ "packageDependencies": [\ - ["eslint", "npm:8.52.0"],\ - ["@eslint-community/eslint-utils", "virtual:e7f048a4398cf12bbd1cde95eff45a3327b9fe42e04c31ed99d0e926830d0b78ff78b511118c2bd3bc6add84782ab97a5b001e972071d6fdbfe85a86c150922a#npm:4.4.0"],\ + ["eslint", "npm:8.57.0"],\ + ["@eslint-community/eslint-utils", "virtual:4286e12a3a0f74af013bc8f16c6d8fdde823cfbf6389660266b171e551f576c805b0a7a8eb2a7087a5cee7dfe6ebb6e1ea3808d93daf915edc95656907a381bb#npm:4.4.0"],\ ["@eslint-community/regexpp", "npm:4.8.0"],\ - ["@eslint/eslintrc", "npm:2.1.2"],\ - ["@eslint/js", "npm:8.52.0"],\ - ["@humanwhocodes/config-array", "npm:0.11.13"],\ + ["@eslint/eslintrc", "npm:2.1.4"],\ + ["@eslint/js", "npm:8.57.0"],\ + ["@humanwhocodes/config-array", "npm:0.11.14"],\ ["@humanwhocodes/module-importer", "npm:1.0.1"],\ ["@nodelib/fs.walk", "npm:1.2.8"],\ ["@ungap/structured-clone", "npm:1.2.0"],\ @@ -4724,6 +4789,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["eslint-compat-utils", [\ + ["npm:0.1.2", {\ + "packageLocation": "./.yarn/cache/eslint-compat-utils-npm-0.1.2-361c6992b1-2315d9db81.zip/node_modules/eslint-compat-utils/",\ + "packageDependencies": [\ + ["eslint-compat-utils", "npm:0.1.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:ff64d06f93654b25d9cae47199e62d111efde9ee7d408664ae44397cd2ddf7906aefd54fcc2557f4d5619d92da3af68c7898126469c2a57c381e05b06491f0da#npm:0.1.2", {\ + "packageLocation": "./.yarn/__virtual__/eslint-compat-utils-virtual-a5f7e6147b/0/cache/eslint-compat-utils-npm-0.1.2-361c6992b1-2315d9db81.zip/node_modules/eslint-compat-utils/",\ + "packageDependencies": [\ + ["eslint-compat-utils", "virtual:ff64d06f93654b25d9cae47199e62d111efde9ee7d408664ae44397cd2ddf7906aefd54fcc2557f4d5619d92da3af68c7898126469c2a57c381e05b06491f0da#npm:0.1.2"],\ + ["@types/eslint", null],\ + ["eslint", "npm:8.57.0"]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "eslint"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["eslint-config-standard", [\ ["npm:17.1.0", {\ "packageLocation": "./.yarn/cache/eslint-config-standard-npm-17.1.0-e72fd623cc-8ed14ffe42.zip/node_modules/eslint-config-standard/",\ @@ -4740,9 +4827,9 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/eslint-plugin-import", null],\ ["@types/eslint-plugin-n", null],\ ["@types/eslint-plugin-promise", null],\ - ["eslint", "npm:8.52.0"],\ - ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.0"],\ - ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.2.0"],\ + ["eslint", "npm:8.57.0"],\ + ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.1"],\ + ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.6.2"],\ ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"]\ ],\ "packagePeers": [\ @@ -4778,10 +4865,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:361651f3309db7531b781b9cf23ea5ff31cb68a9d2e5b7a3be36bfa256e5be480a2eee2a0bb0e03ad6f236a91b841e8cfc2d7af10d590e856ac79becc3546855#npm:2.8.0", {\ - "packageLocation": "./.yarn/__virtual__/eslint-module-utils-virtual-a2b14e4224/0/cache/eslint-module-utils-npm-2.8.0-05e42bcab0-74c6dfea76.zip/node_modules/eslint-module-utils/",\ + ["virtual:caddce79266c9767570f5c081ff9adaab1d8b040965749cfca6a3f3f4fbd011bf36f7d755f18ef80e67a5402a33b10c9e1ffc34efb6909461044fc5d60cfbcd0#npm:2.8.0", {\ + "packageLocation": "./.yarn/__virtual__/eslint-module-utils-virtual-d80573de1e/0/cache/eslint-module-utils-npm-2.8.0-05e42bcab0-74c6dfea76.zip/node_modules/eslint-module-utils/",\ "packageDependencies": [\ - ["eslint-module-utils", "virtual:361651f3309db7531b781b9cf23ea5ff31cb68a9d2e5b7a3be36bfa256e5be480a2eee2a0bb0e03ad6f236a91b841e8cfc2d7af10d590e856ac79becc3546855#npm:2.8.0"],\ + ["eslint-module-utils", "virtual:caddce79266c9767570f5c081ff9adaab1d8b040965749cfca6a3f3f4fbd011bf36f7d755f18ef80e67a5402a33b10c9e1ffc34efb6909461044fc5d60cfbcd0#npm:2.8.0"],\ ["@types/eslint", null],\ ["@types/eslint-import-resolver-node", null],\ ["@types/eslint-import-resolver-typescript", null],\ @@ -4789,7 +4876,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/typescript-eslint__parser", null],\ ["@typescript-eslint/parser", null],\ ["debug", "virtual:2a426afc4b2eef43db12a540d29c2b5476640459bfcd5c24f86bb401cf8cce97e63bd81794d206a5643057e7f662643afd5ce3dfc4d4bfd8e706006c6309c5fa#npm:3.2.7"],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-import-resolver-node", "npm:0.3.9"],\ ["eslint-import-resolver-typescript", null],\ ["eslint-import-resolver-webpack", null]\ @@ -4822,7 +4909,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageDependencies": [\ ["eslint-plugin-cypress", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.15.1"],\ ["@types/eslint", null],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["globals", "npm:13.21.0"]\ ],\ "packagePeers": [\ @@ -4845,7 +4932,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageDependencies": [\ ["eslint-plugin-es", "virtual:5cccaf00e87dfff96dbbb5eaf7a3055373358b8114d6a1adfb32f54ed6b40ba06068d3aa1fdd8062899a0cad040f68c17cc6b72bac2cdbe9700f3d6330d112f3#npm:3.0.1"],\ ["@types/eslint", null],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-utils", "npm:2.1.0"],\ ["regexpp", "npm:3.2.0"]\ ],\ @@ -4857,21 +4944,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["eslint-plugin-es-x", [\ - ["npm:7.1.0", {\ - "packageLocation": "./.yarn/cache/eslint-plugin-es-x-npm-7.1.0-35735e8bbc-a19924313c.zip/node_modules/eslint-plugin-es-x/",\ + ["npm:7.5.0", {\ + "packageLocation": "./.yarn/cache/eslint-plugin-es-x-npm-7.5.0-77e84d6e5d-e770e57df7.zip/node_modules/eslint-plugin-es-x/",\ "packageDependencies": [\ - ["eslint-plugin-es-x", "npm:7.1.0"]\ + ["eslint-plugin-es-x", "npm:7.5.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:990a558e2898625aeb04d5e4d99c65569b456f1205bf2f887fd96310a4f502e48e83c1c993f5a46d1cb27cf7bf7d2b513e2850b2c2df904e744e7d8325464673#npm:7.1.0", {\ - "packageLocation": "./.yarn/__virtual__/eslint-plugin-es-x-virtual-c166cb743f/0/cache/eslint-plugin-es-x-npm-7.1.0-35735e8bbc-a19924313c.zip/node_modules/eslint-plugin-es-x/",\ + ["virtual:e72a0a9306438b1033938dd0da350cf9f4ec062648c9360382edaa21499b6290430f07b640481cdb3f67c818af79a821eb8f3071ebf7284ab09c47cb982d8502#npm:7.5.0", {\ + "packageLocation": "./.yarn/__virtual__/eslint-plugin-es-x-virtual-ff64d06f93/0/cache/eslint-plugin-es-x-npm-7.5.0-77e84d6e5d-e770e57df7.zip/node_modules/eslint-plugin-es-x/",\ "packageDependencies": [\ - ["eslint-plugin-es-x", "virtual:990a558e2898625aeb04d5e4d99c65569b456f1205bf2f887fd96310a4f502e48e83c1c993f5a46d1cb27cf7bf7d2b513e2850b2c2df904e744e7d8325464673#npm:7.1.0"],\ - ["@eslint-community/eslint-utils", "virtual:e7f048a4398cf12bbd1cde95eff45a3327b9fe42e04c31ed99d0e926830d0b78ff78b511118c2bd3bc6add84782ab97a5b001e972071d6fdbfe85a86c150922a#npm:4.4.0"],\ - ["@eslint-community/regexpp", "npm:4.5.1"],\ + ["eslint-plugin-es-x", "virtual:e72a0a9306438b1033938dd0da350cf9f4ec062648c9360382edaa21499b6290430f07b640481cdb3f67c818af79a821eb8f3071ebf7284ab09c47cb982d8502#npm:7.5.0"],\ + ["@eslint-community/eslint-utils", "virtual:4286e12a3a0f74af013bc8f16c6d8fdde823cfbf6389660266b171e551f576c805b0a7a8eb2a7087a5cee7dfe6ebb6e1ea3808d93daf915edc95656907a381bb#npm:4.4.0"],\ + ["@eslint-community/regexpp", "npm:4.10.0"],\ ["@types/eslint", null],\ - ["eslint", "npm:8.52.0"]\ + ["eslint", "npm:8.57.0"],\ + ["eslint-compat-utils", "virtual:ff64d06f93654b25d9cae47199e62d111efde9ee7d408664ae44397cd2ddf7906aefd54fcc2557f4d5619d92da3af68c7898126469c2a57c381e05b06491f0da#npm:0.1.2"]\ ],\ "packagePeers": [\ "@types/eslint",\ @@ -4881,17 +4969,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["eslint-plugin-import", [\ - ["npm:2.29.0", {\ - "packageLocation": "./.yarn/cache/eslint-plugin-import-npm-2.29.0-9cd6da0b0a-19ee541fb9.zip/node_modules/eslint-plugin-import/",\ + ["npm:2.29.1", {\ + "packageLocation": "./.yarn/cache/eslint-plugin-import-npm-2.29.1-b94305f7dc-e65159aef8.zip/node_modules/eslint-plugin-import/",\ "packageDependencies": [\ - ["eslint-plugin-import", "npm:2.29.0"]\ + ["eslint-plugin-import", "npm:2.29.1"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.0", {\ - "packageLocation": "./.yarn/__virtual__/eslint-plugin-import-virtual-361651f330/0/cache/eslint-plugin-import-npm-2.29.0-9cd6da0b0a-19ee541fb9.zip/node_modules/eslint-plugin-import/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.1", {\ + "packageLocation": "./.yarn/__virtual__/eslint-plugin-import-virtual-caddce7926/0/cache/eslint-plugin-import-npm-2.29.1-b94305f7dc-e65159aef8.zip/node_modules/eslint-plugin-import/",\ "packageDependencies": [\ - ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.0"],\ + ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.1"],\ ["@types/eslint", null],\ ["@types/typescript-eslint__parser", null],\ ["@typescript-eslint/parser", null],\ @@ -4901,9 +4989,9 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["array.prototype.flatmap", "npm:1.3.2"],\ ["debug", "virtual:2a426afc4b2eef43db12a540d29c2b5476640459bfcd5c24f86bb401cf8cce97e63bd81794d206a5643057e7f662643afd5ce3dfc4d4bfd8e706006c6309c5fa#npm:3.2.7"],\ ["doctrine", "npm:2.1.0"],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-import-resolver-node", "npm:0.3.9"],\ - ["eslint-module-utils", "virtual:361651f3309db7531b781b9cf23ea5ff31cb68a9d2e5b7a3be36bfa256e5be480a2eee2a0bb0e03ad6f236a91b841e8cfc2d7af10d590e856ac79becc3546855#npm:2.8.0"],\ + ["eslint-module-utils", "virtual:caddce79266c9767570f5c081ff9adaab1d8b040965749cfca6a3f3f4fbd011bf36f7d755f18ef80e67a5402a33b10c9e1ffc34efb6909461044fc5d60cfbcd0#npm:2.8.0"],\ ["hasown", "npm:2.0.0"],\ ["is-core-module", "npm:2.13.1"],\ ["is-glob", "npm:4.0.3"],\ @@ -4912,7 +5000,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["object.groupby", "npm:1.0.1"],\ ["object.values", "npm:1.1.7"],\ ["semver", "npm:6.3.1"],\ - ["tsconfig-paths", "npm:3.14.2"]\ + ["tsconfig-paths", "npm:3.15.0"]\ ],\ "packagePeers": [\ "@types/eslint",\ @@ -4924,24 +5012,26 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["eslint-plugin-n", [\ - ["npm:16.2.0", {\ - "packageLocation": "./.yarn/cache/eslint-plugin-n-npm-16.2.0-b2b8355312-124ba4f418.zip/node_modules/eslint-plugin-n/",\ + ["npm:16.6.2", {\ + "packageLocation": "./.yarn/cache/eslint-plugin-n-npm-16.6.2-77775852d0-3b468da003.zip/node_modules/eslint-plugin-n/",\ "packageDependencies": [\ - ["eslint-plugin-n", "npm:16.2.0"]\ + ["eslint-plugin-n", "npm:16.6.2"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.2.0", {\ - "packageLocation": "./.yarn/__virtual__/eslint-plugin-n-virtual-990a558e28/0/cache/eslint-plugin-n-npm-16.2.0-b2b8355312-124ba4f418.zip/node_modules/eslint-plugin-n/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.6.2", {\ + "packageLocation": "./.yarn/__virtual__/eslint-plugin-n-virtual-e72a0a9306/0/cache/eslint-plugin-n-npm-16.6.2-77775852d0-3b468da003.zip/node_modules/eslint-plugin-n/",\ "packageDependencies": [\ - ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.2.0"],\ - ["@eslint-community/eslint-utils", "virtual:e7f048a4398cf12bbd1cde95eff45a3327b9fe42e04c31ed99d0e926830d0b78ff78b511118c2bd3bc6add84782ab97a5b001e972071d6fdbfe85a86c150922a#npm:4.4.0"],\ + ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.6.2"],\ + ["@eslint-community/eslint-utils", "virtual:4286e12a3a0f74af013bc8f16c6d8fdde823cfbf6389660266b171e551f576c805b0a7a8eb2a7087a5cee7dfe6ebb6e1ea3808d93daf915edc95656907a381bb#npm:4.4.0"],\ ["@types/eslint", null],\ ["builtins", "npm:5.0.1"],\ - ["eslint", "npm:8.52.0"],\ - ["eslint-plugin-es-x", "virtual:990a558e2898625aeb04d5e4d99c65569b456f1205bf2f887fd96310a4f502e48e83c1c993f5a46d1cb27cf7bf7d2b513e2850b2c2df904e744e7d8325464673#npm:7.1.0"],\ + ["eslint", "npm:8.57.0"],\ + ["eslint-plugin-es-x", "virtual:e72a0a9306438b1033938dd0da350cf9f4ec062648c9360382edaa21499b6290430f07b640481cdb3f67c818af79a821eb8f3071ebf7284ab09c47cb982d8502#npm:7.5.0"],\ ["get-tsconfig", "npm:4.7.2"],\ + ["globals", "npm:13.24.0"],\ ["ignore", "npm:5.2.4"],\ + ["is-builtin-module", "npm:3.2.1"],\ ["is-core-module", "npm:2.12.1"],\ ["minimatch", "npm:3.1.2"],\ ["resolve", "patch:resolve@npm%3A1.22.3#~builtin::version=1.22.3&hash=07638b"],\ @@ -4967,7 +5057,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageDependencies": [\ ["eslint-plugin-node", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:11.1.0"],\ ["@types/eslint", null],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-plugin-es", "virtual:5cccaf00e87dfff96dbbb5eaf7a3055373358b8114d6a1adfb32f54ed6b40ba06068d3aa1fdd8062899a0cad040f68c17cc6b72bac2cdbe9700f3d6330d112f3#npm:3.0.1"],\ ["eslint-utils", "npm:2.1.0"],\ ["ignore", "npm:5.2.0"],\ @@ -4995,7 +5085,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageDependencies": [\ ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"],\ ["@types/eslint", null],\ - ["eslint", "npm:8.52.0"]\ + ["eslint", "npm:8.57.0"]\ ],\ "packagePeers": [\ "@types/eslint",\ @@ -5005,25 +5095,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["eslint-plugin-vue", [\ - ["npm:9.18.1", {\ - "packageLocation": "./.yarn/cache/eslint-plugin-vue-npm-9.18.1-69de725f8c-774c1e9163.zip/node_modules/eslint-plugin-vue/",\ + ["npm:9.23.0", {\ + "packageLocation": "./.yarn/cache/eslint-plugin-vue-npm-9.23.0-d36e581933-acb3a4dd27.zip/node_modules/eslint-plugin-vue/",\ "packageDependencies": [\ - ["eslint-plugin-vue", "npm:9.18.1"]\ + ["eslint-plugin-vue", "npm:9.23.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.18.1", {\ - "packageLocation": "./.yarn/__virtual__/eslint-plugin-vue-virtual-c049280e08/0/cache/eslint-plugin-vue-npm-9.18.1-69de725f8c-774c1e9163.zip/node_modules/eslint-plugin-vue/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.23.0", {\ + "packageLocation": "./.yarn/__virtual__/eslint-plugin-vue-virtual-a9cf4d7c43/0/cache/eslint-plugin-vue-npm-9.23.0-d36e581933-acb3a4dd27.zip/node_modules/eslint-plugin-vue/",\ "packageDependencies": [\ - ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.18.1"],\ - ["@eslint-community/eslint-utils", "virtual:e7f048a4398cf12bbd1cde95eff45a3327b9fe42e04c31ed99d0e926830d0b78ff78b511118c2bd3bc6add84782ab97a5b001e972071d6fdbfe85a86c150922a#npm:4.4.0"],\ + ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.23.0"],\ + ["@eslint-community/eslint-utils", "virtual:4286e12a3a0f74af013bc8f16c6d8fdde823cfbf6389660266b171e551f576c805b0a7a8eb2a7087a5cee7dfe6ebb6e1ea3808d93daf915edc95656907a381bb#npm:4.4.0"],\ ["@types/eslint", null],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["natural-compare", "npm:1.4.0"],\ ["nth-check", "npm:2.1.1"],\ - ["postcss-selector-parser", "npm:6.0.13"],\ - ["semver", "npm:7.5.4"],\ - ["vue-eslint-parser", "virtual:c049280e085a5fbf364ae13382e78afc9030f197042e9af966e5a699f76ded15d4d96a25dcd6b57cec183d656bd2de42ea186e6a928204c48f8702503655cbd4#npm:9.3.1"],\ + ["postcss-selector-parser", "npm:6.0.15"],\ + ["semver", "npm:7.6.0"],\ + ["vue-eslint-parser", "virtual:a9cf4d7c437b03eeeb49b01b4b3434ca26b6f59f98831796df73ffd0744dcbe1bfe9c60f8b7e2c803ab0f29084730afdc0220af1db5d8d3d00531e0df4f49dbe#npm:9.4.2"],\ ["xml-name-validator", "npm:4.0.0"]\ ],\ "packagePeers": [\ @@ -5303,15 +5393,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["foreground-child", [\ - ["npm:2.0.0", {\ - "packageLocation": "./.yarn/cache/foreground-child-npm-2.0.0-80c976b61e-f77ec9aff6.zip/node_modules/foreground-child/",\ - "packageDependencies": [\ - ["foreground-child", "npm:2.0.0"],\ - ["cross-spawn", "npm:7.0.3"],\ - ["signal-exit", "npm:3.0.7"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:3.1.1", {\ "packageLocation": "./.yarn/cache/foreground-child-npm-3.1.1-77e78ed774-139d270bc8.zip/node_modules/foreground-child/",\ "packageDependencies": [\ @@ -5579,6 +5660,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["type-fest", "npm:0.20.2"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:13.24.0", {\ + "packageLocation": "./.yarn/cache/globals-npm-13.24.0-cc7713139c-56066ef058.zip/node_modules/globals/",\ + "packageDependencies": [\ + ["globals", "npm:13.24.0"],\ + ["type-fest", "npm:0.20.2"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["globalthis", [\ @@ -5712,10 +5801,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["highcharts", [\ - ["npm:11.1.0", {\ - "packageLocation": "./.yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip/node_modules/highcharts/",\ + ["npm:11.4.0", {\ + "packageLocation": "./.yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip/node_modules/highcharts/",\ "packageDependencies": [\ - ["highcharts", "npm:11.1.0"]\ + ["highcharts", "npm:11.4.0"]\ ],\ "linkType": "HARD"\ }]\ @@ -5739,20 +5828,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["html-validate", [\ - ["npm:8.7.0", {\ - "packageLocation": "./.yarn/cache/html-validate-npm-8.7.0-7989d0b76e-f1b346bad8.zip/node_modules/html-validate/",\ + ["npm:8.16.0", {\ + "packageLocation": "./.yarn/cache/html-validate-npm-8.16.0-71c478330f-857b05ab87.zip/node_modules/html-validate/",\ "packageDependencies": [\ - ["html-validate", "npm:8.7.0"]\ + ["html-validate", "npm:8.16.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.7.0", {\ - "packageLocation": "./.yarn/__virtual__/html-validate-virtual-c83421120a/0/cache/html-validate-npm-8.7.0-7989d0b76e-f1b346bad8.zip/node_modules/html-validate/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.16.0", {\ + "packageLocation": "./.yarn/__virtual__/html-validate-virtual-127f44ed2d/0/cache/html-validate-npm-8.16.0-71c478330f-857b05ab87.zip/node_modules/html-validate/",\ "packageDependencies": [\ - ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.7.0"],\ + ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.16.0"],\ ["@babel/code-frame", "npm:7.16.7"],\ ["@html-validate/stylish", "npm:4.1.0"],\ - ["@sidvind/better-ajv-errors", "virtual:c83421120a0d81573c2202cf177f92bb37ea59966baf7759b6fa4e9a2982e6fe336614d87630b5be4c85c00ef51834cf2a040f710a51f15e792d3dd3e2377e0f#npm:2.0.0"],\ + ["@sidvind/better-ajv-errors", "virtual:127f44ed2d4bdd83725c8820683da6ed922142e851761151ec4911dcca098a2d4d832b774a31e7abf98185d360ff83c999b319ea3314c86d38eca853072fb0d2#npm:2.1.3"],\ ["@types/jest", null],\ ["@types/jest-diff", null],\ ["@types/jest-snapshot", null],\ @@ -5760,7 +5849,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ajv", "npm:8.11.0"],\ ["deepmerge", "npm:4.3.1"],\ ["glob", "npm:10.2.4"],\ - ["ignore", "npm:5.2.4"],\ + ["ignore", "npm:5.3.1"],\ ["jest", null],\ ["jest-diff", null],\ ["jest-snapshot", null],\ @@ -5791,10 +5880,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:ee0243765cbdf501388f259b4f1148af5bb4df5c2fa392d4cf1f1d61d3475a9c15a5729ae4be6dd2e258041e618368d112e36aa7b208b01a51861aaaf92fa944#npm:2.0.2", {\ - "packageLocation": "./.yarn/__virtual__/htmlnano-virtual-cd24a048c0/0/cache/htmlnano-npm-2.0.2-a89803bfeb-41f9e0c0e5.zip/node_modules/htmlnano/",\ + ["virtual:cdd2835c1202e86fad55b2266578ff3755267672440481af37bdfff670fd205f561469a10385c20d1ff403af7fad49006bc71ffff21d12592a8ebd0c8be79c0c#npm:2.0.2", {\ + "packageLocation": "./.yarn/__virtual__/htmlnano-virtual-d2bb6df599/0/cache/htmlnano-npm-2.0.2-a89803bfeb-41f9e0c0e5.zip/node_modules/htmlnano/",\ "packageDependencies": [\ - ["htmlnano", "virtual:ee0243765cbdf501388f259b4f1148af5bb4df5c2fa392d4cf1f1d61d3475a9c15a5729ae4be6dd2e258041e618368d112e36aa7b208b01a51861aaaf92fa944#npm:2.0.2"],\ + ["htmlnano", "virtual:cdd2835c1202e86fad55b2266578ff3755267672440481af37bdfff670fd205f561469a10385c20d1ff403af7fad49006bc71ffff21d12592a8ebd0c8be79c0c#npm:2.0.2"],\ ["@types/cssnano", null],\ ["@types/postcss", null],\ ["@types/purgecss", null],\ @@ -5938,6 +6027,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ignore", "npm:5.2.4"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:5.3.1", {\ + "packageLocation": "./.yarn/cache/ignore-npm-5.3.1-f6947c5df7-71d7bb4c1d.zip/node_modules/ignore/",\ + "packageDependencies": [\ + ["ignore", "npm:5.3.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["immutable", [\ @@ -6099,6 +6195,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["is-builtin-module", [\ + ["npm:3.2.1", {\ + "packageLocation": "./.yarn/cache/is-builtin-module-npm-3.2.1-2f92a5d353-e8f0ffc19a.zip/node_modules/is-builtin-module/",\ + "packageDependencies": [\ + ["is-builtin-module", "npm:3.2.1"],\ + ["builtin-modules", "npm:3.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["is-callable", [\ ["npm:1.2.4", {\ "packageLocation": "./.yarn/cache/is-callable-npm-1.2.4-03fc17459c-1a28d57dc4.zip/node_modules/is-callable/",\ @@ -6783,19 +6889,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["luxon", [\ - ["npm:3.4.3", {\ - "packageLocation": "./.yarn/cache/luxon-npm-3.4.3-1b54517fa6-3eade81506.zip/node_modules/luxon/",\ + ["npm:3.4.4", {\ + "packageLocation": "./.yarn/cache/luxon-npm-3.4.4-c93f95dde8-36c1f99c47.zip/node_modules/luxon/",\ "packageDependencies": [\ - ["luxon", "npm:3.4.3"]\ + ["luxon", "npm:3.4.4"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["magic-string", [\ - ["npm:0.30.5", {\ - "packageLocation": "./.yarn/cache/magic-string-npm-0.30.5-dffb7e6a73-da10fecff0.zip/node_modules/magic-string/",\ + ["npm:0.30.7", {\ + "packageLocation": "./.yarn/cache/magic-string-npm-0.30.7-0bb5819095-bdf102e36a.zip/node_modules/magic-string/",\ "packageDependencies": [\ - ["magic-string", "npm:0.30.5"],\ + ["magic-string", "npm:0.30.7"],\ ["@jridgewell/sourcemap-codec", "npm:1.4.15"]\ ],\ "linkType": "HARD"\ @@ -7001,13 +7107,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["moment", "npm:2.29.4"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.30.1", {\ + "packageLocation": "./.yarn/cache/moment-npm-2.30.1-1c51a5c631-859236bab1.zip/node_modules/moment/",\ + "packageDependencies": [\ + ["moment", "npm:2.30.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["moment-timezone", [\ - ["npm:0.5.43", {\ - "packageLocation": "./.yarn/cache/moment-timezone-npm-0.5.43-1304d8602a-8075c897ed.zip/node_modules/moment-timezone/",\ + ["npm:0.5.45", {\ + "packageLocation": "./.yarn/cache/moment-timezone-npm-0.5.45-2df3ad72a4-a22e9f983f.zip/node_modules/moment-timezone/",\ "packageDependencies": [\ - ["moment-timezone", "npm:0.5.43"],\ + ["moment-timezone", "npm:0.5.45"],\ ["moment", "npm:2.29.4"]\ ],\ "linkType": "HARD"\ @@ -7037,6 +7150,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["msgpackr", [\ + ["npm:1.10.1", {\ + "packageLocation": "./.yarn/cache/msgpackr-npm-1.10.1-5c5ff5c553-e422d18b01.zip/node_modules/msgpackr/",\ + "packageDependencies": [\ + ["msgpackr", "npm:1.10.1"],\ + ["msgpackr-extract", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:1.6.0", {\ "packageLocation": "./.yarn/cache/msgpackr-npm-1.6.0-de9303a46e-7f94acbe93.zip/node_modules/msgpackr/",\ "packageDependencies": [\ @@ -7086,6 +7207,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["muggle-string", [\ + ["npm:0.4.1", {\ + "packageLocation": "./.yarn/cache/muggle-string-npm-0.4.1-fe3c825cc2-85fe1766d1.zip/node_modules/muggle-string/",\ + "packageDependencies": [\ + ["muggle-string", "npm:0.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["murmurhash-js", [\ ["npm:1.0.0", {\ "packageLocation": "./.yarn/cache/murmurhash-js-npm-1.0.0-b1fa804bc0-083cea92a1.zip/node_modules/murmurhash-js/",\ @@ -7096,37 +7226,38 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["naive-ui", [\ - ["npm:2.35.0", {\ - "packageLocation": "./.yarn/cache/naive-ui-npm-2.35.0-2bb3f5a46d-53239b8cbe.zip/node_modules/naive-ui/",\ + ["npm:2.38.1", {\ + "packageLocation": "./.yarn/cache/naive-ui-npm-2.38.1-0edd2e5816-88a8f981de.zip/node_modules/naive-ui/",\ "packageDependencies": [\ - ["naive-ui", "npm:2.35.0"]\ + ["naive-ui", "npm:2.38.1"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.35.0", {\ - "packageLocation": "./.yarn/__virtual__/naive-ui-virtual-d5901c8fe9/0/cache/naive-ui-npm-2.35.0-2bb3f5a46d-53239b8cbe.zip/node_modules/naive-ui/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.38.1", {\ + "packageLocation": "./.yarn/__virtual__/naive-ui-virtual-32fd9c861d/0/cache/naive-ui-npm-2.38.1-0edd2e5816-88a8f981de.zip/node_modules/naive-ui/",\ "packageDependencies": [\ - ["naive-ui", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.35.0"],\ - ["@css-render/plugin-bem", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.15.12"],\ - ["@css-render/vue3-ssr", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.15.12"],\ + ["naive-ui", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.38.1"],\ + ["@css-render/plugin-bem", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.15.12"],\ + ["@css-render/vue3-ssr", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.15.12"],\ ["@types/katex", "npm:0.16.5"],\ ["@types/lodash", "npm:4.14.200"],\ ["@types/lodash-es", "npm:4.17.10"],\ ["@types/vue", null],\ ["async-validator", "npm:4.2.5"],\ ["css-render", "npm:0.15.12"],\ + ["csstype", "npm:3.1.3"],\ ["date-fns", "npm:2.30.0"],\ - ["date-fns-tz", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:2.0.0"],\ + ["date-fns-tz", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:2.0.0"],\ ["evtd", "npm:0.2.4"],\ ["highlight.js", "npm:11.9.0"],\ ["lodash", "npm:4.17.21"],\ ["lodash-es", "npm:4.17.21"],\ - ["seemly", "npm:0.3.6"],\ + ["seemly", "npm:0.3.8"],\ ["treemate", "npm:0.3.11"],\ - ["vdirs", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.1.8"],\ - ["vooks", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.2.12"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"],\ - ["vueuc", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.4.51"]\ + ["vdirs", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.1.8"],\ + ["vooks", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.2.12"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"],\ + ["vueuc", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.4.58"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -7136,10 +7267,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["nanoid", [\ - ["npm:3.3.6", {\ - "packageLocation": "./.yarn/cache/nanoid-npm-3.3.6-e6d6ae7e71-7d0eda6570.zip/node_modules/nanoid/",\ + ["npm:3.3.7", {\ + "packageLocation": "./.yarn/cache/nanoid-npm-3.3.7-98824ba130-d36c427e53.zip/node_modules/nanoid/",\ "packageDependencies": [\ - ["nanoid", "npm:3.3.6"]\ + ["nanoid", "npm:3.3.7"]\ ],\ "linkType": "HARD"\ }]\ @@ -7473,28 +7604,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["parcel", [\ - ["npm:2.10.0", {\ - "packageLocation": "./.yarn/cache/parcel-npm-2.10.0-8e794fc289-fe25ddcf2d.zip/node_modules/parcel/",\ + ["npm:2.12.0", {\ + "packageLocation": "./.yarn/cache/parcel-npm-2.12.0-96a4bb6cc3-d8e6cb690a.zip/node_modules/parcel/",\ "packageDependencies": [\ - ["parcel", "npm:2.10.0"]\ + ["parcel", "npm:2.12.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.10.0", {\ - "packageLocation": "./.yarn/__virtual__/parcel-virtual-71592776e8/0/cache/parcel-npm-2.10.0-8e794fc289-fe25ddcf2d.zip/node_modules/parcel/",\ - "packageDependencies": [\ - ["parcel", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.10.0"],\ - ["@parcel/config-default", "virtual:71592776e81a3a98123fea990d2adcb9a2eb4cc84ca35ac4be3a6f331fe8d1f764a124c4f9a2dad3afd35076e01667fb0ef9ccd5629fbe405b31f0d1b14a14fd#npm:2.10.0"],\ - ["@parcel/core", "npm:2.10.0"],\ - ["@parcel/diagnostic", "npm:2.10.0"],\ - ["@parcel/events", "npm:2.10.0"],\ - ["@parcel/fs", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/logger", "npm:2.10.0"],\ - ["@parcel/package-manager", "virtual:59eaeeba7a5d21408bb7b40531b36a88648baa29ed841afea77b484ce4124f400b3aed2f6c7b6598bebbcce34fe625391a4c262c0e17b5a4f9e1ebbf693fa69b#npm:2.10.0"],\ - ["@parcel/reporter-cli", "npm:2.10.0"],\ - ["@parcel/reporter-dev-server", "npm:2.10.0"],\ - ["@parcel/reporter-tracer", "npm:2.10.0"],\ - ["@parcel/utils", "npm:2.10.0"],\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.12.0", {\ + "packageLocation": "./.yarn/__virtual__/parcel-virtual-fdd74b573c/0/cache/parcel-npm-2.12.0-96a4bb6cc3-d8e6cb690a.zip/node_modules/parcel/",\ + "packageDependencies": [\ + ["parcel", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.12.0"],\ + ["@parcel/config-default", "virtual:fdd74b573cf769bcde15fb47c39fbe0d73f59838182900fd59d3d43b2214ea01b1d45084fb49d0c192fc3e8a49adea5782afcb7fe14e09c63bedaf09f4939e35#npm:2.12.0"],\ + ["@parcel/core", "npm:2.12.0"],\ + ["@parcel/diagnostic", "npm:2.12.0"],\ + ["@parcel/events", "npm:2.12.0"],\ + ["@parcel/fs", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/logger", "npm:2.12.0"],\ + ["@parcel/package-manager", "virtual:8f08b883d4cc438aa2ec719eb5cec278f9ea627197c55f35530bcaf9cd4e4738e04be8abe946bd2702b3f5c94b812f529f1b87c05c7d6de04e1ade9b3f3e00f6#npm:2.12.0"],\ + ["@parcel/reporter-cli", "npm:2.12.0"],\ + ["@parcel/reporter-dev-server", "npm:2.12.0"],\ + ["@parcel/reporter-tracer", "npm:2.12.0"],\ + ["@parcel/utils", "npm:2.12.0"],\ ["@types/parcel__core", null],\ ["chalk", "npm:4.1.2"],\ ["commander", "npm:7.2.0"],\ @@ -7621,7 +7752,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@vue/composition-api", null],\ ["@vue/devtools-api", "npm:6.5.0"],\ ["typescript", null],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"],\ ["vue-demi", "virtual:cf6f7439ee76dfd2e7f8f2565ae847d76901434fc49c65702190cdf3d1c61e61c701a5c45b514c4bdeacb8f4bcac9c8a98bd4db3d0bc8e403d9e8db2cf14372a#npm:0.14.5"]\ ],\ "packagePeers": [\ @@ -7652,7 +7783,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/vue__composition-api", null],\ ["@vue/composition-api", null],\ ["pinia", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.1.7"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"],\ ["vue-demi", "virtual:f56fcf19bbebc2ada1b28955da8cc216b1e9a569a1a7337d2d1926c1ebd1bc7a5bd91aedae1d05c15c8562f33caf7c59bd3020a667340f6bdc6a7b13fc2ba847#npm:0.12.5"]\ ],\ "packagePeers": [\ @@ -7667,21 +7798,21 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["postcss", [\ - ["npm:8.4.27", {\ - "packageLocation": "./.yarn/cache/postcss-npm-8.4.27-2a9f5f8f40-1cdd0c2988.zip/node_modules/postcss/",\ + ["npm:8.4.33", {\ + "packageLocation": "./.yarn/cache/postcss-npm-8.4.33-6ba8157009-6f98b2af4b.zip/node_modules/postcss/",\ "packageDependencies": [\ - ["postcss", "npm:8.4.27"],\ - ["nanoid", "npm:3.3.6"],\ + ["postcss", "npm:8.4.33"],\ + ["nanoid", "npm:3.3.7"],\ ["picocolors", "npm:1.0.0"],\ ["source-map-js", "npm:1.0.2"]\ ],\ "linkType": "HARD"\ }],\ - ["npm:8.4.31", {\ - "packageLocation": "./.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip/node_modules/postcss/",\ + ["npm:8.4.35", {\ + "packageLocation": "./.yarn/cache/postcss-npm-8.4.35-6bc1848fff-cf3c3124d3.zip/node_modules/postcss/",\ "packageDependencies": [\ - ["postcss", "npm:8.4.31"],\ - ["nanoid", "npm:3.3.6"],\ + ["postcss", "npm:8.4.35"],\ + ["nanoid", "npm:3.3.7"],\ ["picocolors", "npm:1.0.0"],\ ["source-map-js", "npm:1.0.2"]\ ],\ @@ -7689,10 +7820,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["postcss-selector-parser", [\ - ["npm:6.0.13", {\ - "packageLocation": "./.yarn/cache/postcss-selector-parser-npm-6.0.13-f732d92326-f89163338a.zip/node_modules/postcss-selector-parser/",\ + ["npm:6.0.15", {\ + "packageLocation": "./.yarn/cache/postcss-selector-parser-npm-6.0.15-0ec4819b4e-57decb9415.zip/node_modules/postcss-selector-parser/",\ "packageDependencies": [\ - ["postcss-selector-parser", "npm:6.0.13"],\ + ["postcss-selector-parser", "npm:6.0.15"],\ ["cssesc", "npm:3.0.0"],\ ["util-deprecate", "npm:1.0.2"]\ ],\ @@ -8171,10 +8302,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["rollup", [\ - ["npm:3.28.0", {\ - "packageLocation": "./.yarn/cache/rollup-npm-3.28.0-4ab1b4022e-6ded4a0d3c.zip/node_modules/rollup/",\ + ["npm:3.29.4", {\ + "packageLocation": "./.yarn/cache/rollup-npm-3.29.4-5e5e5f2087-8bb20a39c8.zip/node_modules/rollup/",\ "packageDependencies": [\ - ["rollup", "npm:3.28.0"],\ + ["rollup", "npm:3.29.4"],\ ["fsevents", "patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=18f3a7"]\ ],\ "linkType": "HARD"\ @@ -8185,40 +8316,41 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageLocation": "./",\ "packageDependencies": [\ ["root-workspace-0b6124", "workspace:."],\ - ["@fullcalendar/bootstrap5", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/core", "npm:6.1.9"],\ - ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/icalendar", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/interaction", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/list", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/luxon3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/timegrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@fullcalendar/vue3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.9"],\ - ["@parcel/optimizer-data-url", "npm:2.10.0"],\ - ["@parcel/transformer-inline-string", "npm:2.10.0"],\ - ["@parcel/transformer-sass", "npm:2.10.0"],\ + ["@fullcalendar/bootstrap5", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/core", "npm:6.1.11"],\ + ["@fullcalendar/daygrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/icalendar", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/interaction", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/list", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/luxon3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/timegrid", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@fullcalendar/vue3", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.11"],\ + ["@parcel/optimizer-data-url", "npm:2.12.0"],\ + ["@parcel/transformer-inline-string", "npm:2.12.0"],\ + ["@parcel/transformer-sass", "npm:2.12.0"],\ ["@popperjs/core", "npm:2.11.8"],\ - ["@rollup/pluginutils", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.0.5"],\ + ["@rollup/pluginutils", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.1.0"],\ ["@twuni/emojify", "npm:1.0.2"],\ - ["@vitejs/plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.4.0"],\ - ["bootstrap", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.2"],\ - ["bootstrap-icons", "npm:1.11.1"],\ + ["@vitejs/plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.6.2"],\ + ["@vue/language-plugin-pug", "npm:2.0.7"],\ + ["bootstrap", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:5.3.3"],\ + ["bootstrap-icons", "npm:1.11.3"],\ ["browser-fs-access", "npm:0.35.0"],\ ["browserlist", "npm:1.0.1"],\ - ["c8", "npm:8.0.1"],\ - ["caniuse-lite", "npm:1.0.30001538"],\ - ["d3", "npm:7.8.5"],\ - ["eslint", "npm:8.52.0"],\ + ["c8", "npm:9.1.0"],\ + ["caniuse-lite", "npm:1.0.30001599"],\ + ["d3", "npm:7.9.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-config-standard", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:17.1.0"],\ ["eslint-plugin-cypress", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.15.1"],\ - ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.0"],\ - ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.2.0"],\ + ["eslint-plugin-import", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.29.1"],\ + ["eslint-plugin-n", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:16.6.2"],\ ["eslint-plugin-node", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:11.1.0"],\ ["eslint-plugin-promise", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:6.1.1"],\ - ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.18.1"],\ + ["eslint-plugin-vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:9.23.0"],\ ["file-saver", "npm:2.0.5"],\ - ["highcharts", "npm:11.1.0"],\ - ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.7.0"],\ + ["highcharts", "npm:11.4.0"],\ + ["html-validate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:8.16.0"],\ ["ical.js", "npm:1.5.0"],\ ["jquery", "npm:3.7.1"],\ ["jquery-migrate", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.1"],\ @@ -8226,28 +8358,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["list.js", "npm:2.3.1"],\ ["lodash", "npm:4.17.21"],\ ["lodash-es", "npm:4.17.21"],\ - ["luxon", "npm:3.4.3"],\ - ["moment", "npm:2.29.4"],\ - ["moment-timezone", "npm:0.5.43"],\ + ["luxon", "npm:3.4.4"],\ + ["moment", "npm:2.30.1"],\ + ["moment-timezone", "npm:0.5.45"],\ ["ms", "npm:2.1.3"],\ ["murmurhash-js", "npm:1.0.0"],\ - ["naive-ui", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.35.0"],\ - ["parcel", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.10.0"],\ + ["naive-ui", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.38.1"],\ + ["parcel", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.12.0"],\ ["pinia", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:2.1.7"],\ ["pinia-plugin-persist", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:1.0.0"],\ ["pug", "npm:3.0.2"],\ - ["sass", "npm:1.69.4"],\ + ["sass", "npm:1.72.0"],\ ["seedrandom", "npm:3.0.5"],\ ["select2", "npm:4.1.0-rc.0"],\ ["select2-bootstrap-5-theme", "npm:1.3.0"],\ ["send", "npm:0.18.0"],\ ["shepherd.js", "npm:11.2.0"],\ ["slugify", "npm:1.6.6"],\ - ["sortablejs", "npm:1.15.0"],\ + ["sortablejs", "npm:1.15.2"],\ ["vanillajs-datepicker", "npm:1.3.4"],\ - ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.0"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"],\ - ["vue-router", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.2.5"],\ + ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.2"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"],\ + ["vue-router", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.3.0"],\ ["zxcvbn", "npm:4.4.2"]\ ],\ "linkType": "SOFT"\ @@ -8333,10 +8465,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["npm:1.69.4", {\ - "packageLocation": "./.yarn/cache/sass-npm-1.69.4-bea57e4b30-ed5558445b.zip/node_modules/sass/",\ + ["npm:1.72.0", {\ + "packageLocation": "./.yarn/cache/sass-npm-1.72.0-fb38bb530c-f420079c7d.zip/node_modules/sass/",\ "packageDependencies": [\ - ["sass", "npm:1.69.4"],\ + ["sass", "npm:1.72.0"],\ ["chokidar", "npm:3.5.3"],\ ["immutable", "npm:4.0.0"],\ ["source-map-js", "npm:1.0.2"]\ @@ -8360,6 +8492,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["seemly", "npm:0.3.6"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:0.3.8", {\ + "packageLocation": "./.yarn/cache/seemly-npm-0.3.8-4940336497-98171fd4d9.zip/node_modules/seemly/",\ + "packageDependencies": [\ + ["seemly", "npm:0.3.8"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["select2", [\ @@ -8427,6 +8566,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["lru-cache", "npm:6.0.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:7.6.0", {\ + "packageLocation": "./.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip/node_modules/semver/",\ + "packageDependencies": [\ + ["semver", "npm:7.6.0"],\ + ["lru-cache", "npm:6.0.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["send", [\ @@ -8603,10 +8750,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["sortablejs", [\ - ["npm:1.15.0", {\ - "packageLocation": "./.yarn/cache/sortablejs-npm-1.15.0-f3a393abcc-bb82223a66.zip/node_modules/sortablejs/",\ + ["npm:1.15.2", {\ + "packageLocation": "./.yarn/cache/sortablejs-npm-1.15.2-73347ae85a-36b20b144f.zip/node_modules/sortablejs/",\ "packageDependencies": [\ - ["sortablejs", "npm:1.15.0"]\ + ["sortablejs", "npm:1.15.2"]\ ],\ "linkType": "HARD"\ }]\ @@ -8923,10 +9070,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["tsconfig-paths", [\ - ["npm:3.14.2", {\ - "packageLocation": "./.yarn/cache/tsconfig-paths-npm-3.14.2-90ce75420d-a6162eaa1a.zip/node_modules/tsconfig-paths/",\ + ["npm:3.15.0", {\ + "packageLocation": "./.yarn/cache/tsconfig-paths-npm-3.15.0-ff68930e0e-59f35407a3.zip/node_modules/tsconfig-paths/",\ "packageDependencies": [\ - ["tsconfig-paths", "npm:3.14.2"],\ + ["tsconfig-paths", "npm:3.15.0"],\ ["@types/json5", "npm:0.0.29"],\ ["json5", "npm:1.0.2"],\ ["minimist", "npm:1.2.6"],\ @@ -9104,13 +9251,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.1.8", {\ - "packageLocation": "./.yarn/__virtual__/vdirs-virtual-35aac8a6c6/0/cache/vdirs-npm-0.1.8-59a32a98d6-a7be8ccad3.zip/node_modules/vdirs/",\ + ["virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.1.8", {\ + "packageLocation": "./.yarn/__virtual__/vdirs-virtual-6e8e27ef7d/0/cache/vdirs-npm-0.1.8-59a32a98d6-a7be8ccad3.zip/node_modules/vdirs/",\ "packageDependencies": [\ - ["vdirs", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.1.8"],\ + ["vdirs", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.1.8"],\ ["@types/vue", null],\ ["evtd", "npm:0.2.3"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -9120,17 +9267,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["vite", [\ - ["npm:4.5.0", {\ - "packageLocation": "./.yarn/cache/vite-npm-4.5.0-6fb40946d7-06f1a4c858.zip/node_modules/vite/",\ + ["npm:4.5.2", {\ + "packageLocation": "./.yarn/cache/vite-npm-4.5.2-e430b2c117-9d1f84f703.zip/node_modules/vite/",\ "packageDependencies": [\ - ["vite", "npm:4.5.0"]\ + ["vite", "npm:4.5.2"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.0", {\ - "packageLocation": "./.yarn/__virtual__/vite-virtual-48c4f851a8/0/cache/vite-npm-4.5.0-6fb40946d7-06f1a4c858.zip/node_modules/vite/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.2", {\ + "packageLocation": "./.yarn/__virtual__/vite-virtual-8f548b7c00/0/cache/vite-npm-4.5.2-e430b2c117-9d1f84f703.zip/node_modules/vite/",\ "packageDependencies": [\ - ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.0"],\ + ["vite", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.5.2"],\ ["@types/less", null],\ ["@types/lightningcss", null],\ ["@types/node", null],\ @@ -9142,9 +9289,9 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["fsevents", "patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=18f3a7"],\ ["less", null],\ ["lightningcss", null],\ - ["postcss", "npm:8.4.27"],\ - ["rollup", "npm:3.28.0"],\ - ["sass", "npm:1.69.4"],\ + ["postcss", "npm:8.4.33"],\ + ["rollup", "npm:3.29.4"],\ + ["sass", "npm:1.72.0"],\ ["stylus", null],\ ["sugarss", null],\ ["terser", null]\ @@ -9176,6 +9323,46 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["volar-service-html", [\ + ["npm:0.0.34", {\ + "packageLocation": "./.yarn/cache/volar-service-html-npm-0.0.34-32b6d24136-83b50cd805.zip/node_modules/volar-service-html/",\ + "packageDependencies": [\ + ["volar-service-html", "npm:0.0.34"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:6f5429e17c4ecd390af605a4e97ecc7b34f2f1374a5e30c21f0a978cbdc904738a42d0d6f5d44d2e969250218b3c205853d6afefd88b87bcda877286d12bef83#npm:0.0.34", {\ + "packageLocation": "./.yarn/__virtual__/volar-service-html-virtual-5a9107a24d/0/cache/volar-service-html-npm-0.0.34-32b6d24136-83b50cd805.zip/node_modules/volar-service-html/",\ + "packageDependencies": [\ + ["volar-service-html", "virtual:6f5429e17c4ecd390af605a4e97ecc7b34f2f1374a5e30c21f0a978cbdc904738a42d0d6f5d44d2e969250218b3c205853d6afefd88b87bcda877286d12bef83#npm:0.0.34"],\ + ["@types/volar__language-service", null],\ + ["@volar/language-service", "npm:2.1.4"],\ + ["vscode-html-languageservice", "npm:5.1.2"],\ + ["vscode-languageserver-textdocument", "npm:1.0.11"],\ + ["vscode-uri", "npm:3.0.8"]\ + ],\ + "packagePeers": [\ + "@types/volar__language-service",\ + "@volar/language-service"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["volar-service-pug", [\ + ["npm:0.0.34", {\ + "packageLocation": "./.yarn/cache/volar-service-pug-npm-0.0.34-6f5429e17c-4691aa1c8e.zip/node_modules/volar-service-pug/",\ + "packageDependencies": [\ + ["volar-service-pug", "npm:0.0.34"],\ + ["@volar/language-service", "npm:2.1.4"],\ + ["pug-lexer", "npm:5.0.1"],\ + ["pug-parser", "npm:6.0.0"],\ + ["volar-service-html", "virtual:6f5429e17c4ecd390af605a4e97ecc7b34f2f1374a5e30c21f0a978cbdc904738a42d0d6f5d44d2e969250218b3c205853d6afefd88b87bcda877286d12bef83#npm:0.0.34"],\ + ["vscode-html-languageservice", "npm:5.1.2"],\ + ["vscode-languageserver-textdocument", "npm:1.0.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["vooks", [\ ["npm:0.2.12", {\ "packageLocation": "./.yarn/cache/vooks-npm-0.2.12-0d1a2d856b-e6841ec5b6.zip/node_modules/vooks/",\ @@ -9184,13 +9371,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.2.12", {\ - "packageLocation": "./.yarn/__virtual__/vooks-virtual-f099143e8a/0/cache/vooks-npm-0.2.12-0d1a2d856b-e6841ec5b6.zip/node_modules/vooks/",\ + ["virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.2.12", {\ + "packageLocation": "./.yarn/__virtual__/vooks-virtual-ca0a47c4bf/0/cache/vooks-npm-0.2.12-0d1a2d856b-e6841ec5b6.zip/node_modules/vooks/",\ "packageDependencies": [\ - ["vooks", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.2.12"],\ + ["vooks", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.2.12"],\ ["@types/vue", null],\ ["evtd", "npm:0.2.3"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -9199,24 +9386,84 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["vscode-html-languageservice", [\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/vscode-html-languageservice-npm-5.1.2-2ea2618bdd-3a2a5ee5ad.zip/node_modules/vscode-html-languageservice/",\ + "packageDependencies": [\ + ["vscode-html-languageservice", "npm:5.1.2"],\ + ["@vscode/l10n", "npm:0.0.18"],\ + ["vscode-languageserver-textdocument", "npm:1.0.11"],\ + ["vscode-languageserver-types", "npm:3.17.5"],\ + ["vscode-uri", "npm:3.0.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vscode-jsonrpc", [\ + ["npm:8.2.0", {\ + "packageLocation": "./.yarn/cache/vscode-jsonrpc-npm-8.2.0-b7d2e5b553-f302a01e59.zip/node_modules/vscode-jsonrpc/",\ + "packageDependencies": [\ + ["vscode-jsonrpc", "npm:8.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vscode-languageserver-protocol", [\ + ["npm:3.17.5", {\ + "packageLocation": "./.yarn/cache/vscode-languageserver-protocol-npm-3.17.5-2b07e16989-dfb42d276d.zip/node_modules/vscode-languageserver-protocol/",\ + "packageDependencies": [\ + ["vscode-languageserver-protocol", "npm:3.17.5"],\ + ["vscode-jsonrpc", "npm:8.2.0"],\ + ["vscode-languageserver-types", "npm:3.17.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vscode-languageserver-textdocument", [\ + ["npm:1.0.11", {\ + "packageLocation": "./.yarn/cache/vscode-languageserver-textdocument-npm-1.0.11-6fc94d2b7b-ea7cdc9d4f.zip/node_modules/vscode-languageserver-textdocument/",\ + "packageDependencies": [\ + ["vscode-languageserver-textdocument", "npm:1.0.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vscode-languageserver-types", [\ + ["npm:3.17.5", {\ + "packageLocation": "./.yarn/cache/vscode-languageserver-types-npm-3.17.5-aca3b71a5a-79b420e757.zip/node_modules/vscode-languageserver-types/",\ + "packageDependencies": [\ + ["vscode-languageserver-types", "npm:3.17.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vscode-uri", [\ + ["npm:3.0.8", {\ + "packageLocation": "./.yarn/cache/vscode-uri-npm-3.0.8-56f46b9d24-5142491268.zip/node_modules/vscode-uri/",\ + "packageDependencies": [\ + ["vscode-uri", "npm:3.0.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["vue", [\ - ["npm:3.3.7", {\ - "packageLocation": "./.yarn/cache/vue-npm-3.3.7-6f764474d4-463599d2a5.zip/node_modules/vue/",\ + ["npm:3.4.21", {\ + "packageLocation": "./.yarn/cache/vue-npm-3.4.21-02110aa6d9-3c477982a0.zip/node_modules/vue/",\ "packageDependencies": [\ - ["vue", "npm:3.3.7"]\ + ["vue", "npm:3.4.21"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7", {\ - "packageLocation": "./.yarn/__virtual__/vue-virtual-3abdc07485/0/cache/vue-npm-3.3.7-6f764474d4-463599d2a5.zip/node_modules/vue/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21", {\ + "packageLocation": "./.yarn/__virtual__/vue-virtual-b79af6274d/0/cache/vue-npm-3.4.21-02110aa6d9-3c477982a0.zip/node_modules/vue/",\ "packageDependencies": [\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"],\ ["@types/typescript", null],\ - ["@vue/compiler-dom", "npm:3.3.7"],\ - ["@vue/compiler-sfc", "npm:3.3.7"],\ - ["@vue/runtime-dom", "npm:3.3.7"],\ - ["@vue/server-renderer", "virtual:3abdc07485f088dc7c94fb6016205a603cbf92506f0de3d8b0b3c64fb975ecc9ffddafd33901d1bbe205fa301d19a0b5580e4aae44cfc80bff10566fc4d7e20b#npm:3.3.7"],\ - ["@vue/shared", "npm:3.3.7"],\ + ["@vue/compiler-dom", "npm:3.4.21"],\ + ["@vue/compiler-sfc", "npm:3.4.21"],\ + ["@vue/runtime-dom", "npm:3.4.21"],\ + ["@vue/server-renderer", "virtual:b79af6274dddda2b283f42be2b827e30c3e5389bce2938ee73bdb74ee9781811fc079c6836719e57940708d59b3beeb14d9e3c12f37f2d22582a53e6c32e4c97#npm:3.4.21"],\ + ["@vue/shared", "npm:3.4.21"],\ ["typescript", null]\ ],\ "packagePeers": [\ @@ -9248,7 +9495,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/vue", null],\ ["@types/vue__composition-api", null],\ ["@vue/composition-api", null],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -9265,7 +9512,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/vue", null],\ ["@types/vue__composition-api", null],\ ["@vue/composition-api", null],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -9277,20 +9524,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["vue-eslint-parser", [\ - ["npm:9.3.1", {\ - "packageLocation": "./.yarn/cache/vue-eslint-parser-npm-9.3.1-a0feb51670-6d1476b45f.zip/node_modules/vue-eslint-parser/",\ + ["npm:9.4.2", {\ + "packageLocation": "./.yarn/cache/vue-eslint-parser-npm-9.4.2-3e4e696025-67f14c8ea1.zip/node_modules/vue-eslint-parser/",\ "packageDependencies": [\ - ["vue-eslint-parser", "npm:9.3.1"]\ + ["vue-eslint-parser", "npm:9.4.2"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:c049280e085a5fbf364ae13382e78afc9030f197042e9af966e5a699f76ded15d4d96a25dcd6b57cec183d656bd2de42ea186e6a928204c48f8702503655cbd4#npm:9.3.1", {\ - "packageLocation": "./.yarn/__virtual__/vue-eslint-parser-virtual-ac996f3737/0/cache/vue-eslint-parser-npm-9.3.1-a0feb51670-6d1476b45f.zip/node_modules/vue-eslint-parser/",\ + ["virtual:a9cf4d7c437b03eeeb49b01b4b3434ca26b6f59f98831796df73ffd0744dcbe1bfe9c60f8b7e2c803ab0f29084730afdc0220af1db5d8d3d00531e0df4f49dbe#npm:9.4.2", {\ + "packageLocation": "./.yarn/__virtual__/vue-eslint-parser-virtual-6710b98248/0/cache/vue-eslint-parser-npm-9.4.2-3e4e696025-67f14c8ea1.zip/node_modules/vue-eslint-parser/",\ "packageDependencies": [\ - ["vue-eslint-parser", "virtual:c049280e085a5fbf364ae13382e78afc9030f197042e9af966e5a699f76ded15d4d96a25dcd6b57cec183d656bd2de42ea186e6a928204c48f8702503655cbd4#npm:9.3.1"],\ + ["vue-eslint-parser", "virtual:a9cf4d7c437b03eeeb49b01b4b3434ca26b6f59f98831796df73ffd0744dcbe1bfe9c60f8b7e2c803ab0f29084730afdc0220af1db5d8d3d00531e0df4f49dbe#npm:9.4.2"],\ ["@types/eslint", null],\ ["debug", "virtual:b86a9fb34323a98c6519528ed55faa0d9b44ca8879307c0b29aa384bde47ff59a7d0c9051b31246f14521dfb71ba3c5d6d0b35c29fffc17bf875aa6ad977d9e8#npm:4.3.4"],\ - ["eslint", "npm:8.52.0"],\ + ["eslint", "npm:8.57.0"],\ ["eslint-scope", "npm:7.1.1"],\ ["eslint-visitor-keys", "npm:3.3.0"],\ ["espree", "npm:9.3.2"],\ @@ -9306,20 +9553,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["vue-router", [\ - ["npm:4.2.5", {\ - "packageLocation": "./.yarn/cache/vue-router-npm-4.2.5-3479f41e41-2449db4f3a.zip/node_modules/vue-router/",\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/vue-router-npm-4.3.0-b765d40138-0059261d39.zip/node_modules/vue-router/",\ "packageDependencies": [\ - ["vue-router", "npm:4.2.5"]\ + ["vue-router", "npm:4.3.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.2.5", {\ - "packageLocation": "./.yarn/__virtual__/vue-router-virtual-2017aa030a/0/cache/vue-router-npm-4.2.5-3479f41e41-2449db4f3a.zip/node_modules/vue-router/",\ + ["virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.3.0", {\ + "packageLocation": "./.yarn/__virtual__/vue-router-virtual-82f54143bf/0/cache/vue-router-npm-4.3.0-b765d40138-0059261d39.zip/node_modules/vue-router/",\ "packageDependencies": [\ - ["vue-router", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.2.5"],\ + ["vue-router", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:4.3.0"],\ ["@types/vue", null],\ - ["@vue/devtools-api", "npm:6.5.0"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["@vue/devtools-api", "npm:6.6.1"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ @@ -9329,26 +9576,26 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["vueuc", [\ - ["npm:0.4.51", {\ - "packageLocation": "./.yarn/cache/vueuc-npm-0.4.51-794074113f-7969659fac.zip/node_modules/vueuc/",\ + ["npm:0.4.58", {\ + "packageLocation": "./.yarn/cache/vueuc-npm-0.4.58-be5584770c-fb0b9a69be.zip/node_modules/vueuc/",\ "packageDependencies": [\ - ["vueuc", "npm:0.4.51"]\ + ["vueuc", "npm:0.4.58"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.4.51", {\ - "packageLocation": "./.yarn/__virtual__/vueuc-virtual-07229bbf54/0/cache/vueuc-npm-0.4.51-794074113f-7969659fac.zip/node_modules/vueuc/",\ + ["virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.4.58", {\ + "packageLocation": "./.yarn/__virtual__/vueuc-virtual-2366be83ef/0/cache/vueuc-npm-0.4.58-be5584770c-fb0b9a69be.zip/node_modules/vueuc/",\ "packageDependencies": [\ - ["vueuc", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.4.51"],\ - ["@css-render/vue3-ssr", "virtual:07229bbf54bc488d21e48f65df3fcd2cdabd1e401dfffccce7403d04695be90e478a0d508694f896481602b0f9db804b9f384dfa051fe08e896fd18fd1fe0b6b#npm:0.15.10"],\ + ["vueuc", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.4.58"],\ + ["@css-render/vue3-ssr", "virtual:2366be83ef58a728ebb5a5e9ed4600f4465f98b2a844262fcfbe89415361d5d5f9e964ec3b9a72d6a5004f37c1024d017c65e67473dd9cc39cd61f51768c65e6#npm:0.15.10"],\ ["@juggle/resize-observer", "npm:3.3.1"],\ ["@types/vue", null],\ ["css-render", "npm:0.15.10"],\ ["evtd", "npm:0.2.4"],\ ["seemly", "npm:0.3.6"],\ - ["vdirs", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.1.8"],\ - ["vooks", "virtual:d5901c8fe9a0c32ef9bd30914b8624afdd53ad520846726499200f014090a72c0a1d5e3737654e39af21acf7bf6f0993bedc3c908b3b8804daa47faed23d0085#npm:0.2.12"],\ - ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.3.7"]\ + ["vdirs", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.1.8"],\ + ["vooks", "virtual:32fd9c861d759cd42dabb479e4fd652286369e629cc7ef63c9cf4f1af5387c64be25fafc985023ea8534b1ec1f4cc92e6c918c7f3b594aa0f8acad026c671a6a#npm:0.2.12"],\ + ["vue", "virtual:dc3fc578bfa5e06182a4d2be39ede0bc5b74940b1ffe0d70c26892ab140a4699787750fba175dc306292e80b4aa2c8c5f68c2a821e69b2c37e360c0dff36ff66#npm:3.4.21"]\ ],\ "packagePeers": [\ "@types/vue",\ diff --git a/.yarn/cache/@babel-parser-npm-7.23.0-8a7b151672-453fdf8b9e.zip b/.yarn/cache/@babel-parser-npm-7.23.0-8a7b151672-453fdf8b9e.zip deleted file mode 100644 index d7543e4a61..0000000000 Binary files a/.yarn/cache/@babel-parser-npm-7.23.0-8a7b151672-453fdf8b9e.zip and /dev/null differ diff --git a/.yarn/cache/@babel-parser-npm-7.23.9-720a0b56cb-e7cd4960ac.zip b/.yarn/cache/@babel-parser-npm-7.23.9-720a0b56cb-e7cd4960ac.zip new file mode 100644 index 0000000000..7b6c44fc3f Binary files /dev/null and b/.yarn/cache/@babel-parser-npm-7.23.9-720a0b56cb-e7cd4960ac.zip differ diff --git a/.yarn/cache/@eslint-community-regexpp-npm-4.10.0-6bfb984c81-2a6e345429.zip b/.yarn/cache/@eslint-community-regexpp-npm-4.10.0-6bfb984c81-2a6e345429.zip new file mode 100644 index 0000000000..7ef5a48973 Binary files /dev/null and b/.yarn/cache/@eslint-community-regexpp-npm-4.10.0-6bfb984c81-2a6e345429.zip differ diff --git a/.yarn/cache/@eslint-community-regexpp-npm-4.5.1-bf72922237-6d901166d6.zip b/.yarn/cache/@eslint-community-regexpp-npm-4.5.1-bf72922237-6d901166d6.zip deleted file mode 100644 index 0f8176b616..0000000000 Binary files a/.yarn/cache/@eslint-community-regexpp-npm-4.5.1-bf72922237-6d901166d6.zip and /dev/null differ diff --git a/.yarn/cache/@eslint-eslintrc-npm-2.1.2-feb0771c9f-bc742a1e3b.zip b/.yarn/cache/@eslint-eslintrc-npm-2.1.2-feb0771c9f-bc742a1e3b.zip deleted file mode 100644 index 43f6713681..0000000000 Binary files a/.yarn/cache/@eslint-eslintrc-npm-2.1.2-feb0771c9f-bc742a1e3b.zip and /dev/null differ diff --git a/.yarn/cache/@eslint-eslintrc-npm-2.1.4-1ff4b5f908-10957c7592.zip b/.yarn/cache/@eslint-eslintrc-npm-2.1.4-1ff4b5f908-10957c7592.zip new file mode 100644 index 0000000000..58788ff7a6 Binary files /dev/null and b/.yarn/cache/@eslint-eslintrc-npm-2.1.4-1ff4b5f908-10957c7592.zip differ diff --git a/.yarn/cache/@eslint-js-npm-8.52.0-801dbdf7b0-490893b809.zip b/.yarn/cache/@eslint-js-npm-8.57.0-00ead3710a-315dc65b0e.zip similarity index 50% rename from .yarn/cache/@eslint-js-npm-8.52.0-801dbdf7b0-490893b809.zip rename to .yarn/cache/@eslint-js-npm-8.57.0-00ead3710a-315dc65b0e.zip index 1c059425f2..82eab16e7c 100644 Binary files a/.yarn/cache/@eslint-js-npm-8.52.0-801dbdf7b0-490893b809.zip and b/.yarn/cache/@eslint-js-npm-8.57.0-00ead3710a-315dc65b0e.zip differ diff --git a/.yarn/cache/@fullcalendar-bootstrap5-npm-6.1.9-ef68c3c094-1d6168fafc.zip b/.yarn/cache/@fullcalendar-bootstrap5-npm-6.1.11-6e0fbf281a-a0c3b94346.zip similarity index 52% rename from .yarn/cache/@fullcalendar-bootstrap5-npm-6.1.9-ef68c3c094-1d6168fafc.zip rename to .yarn/cache/@fullcalendar-bootstrap5-npm-6.1.11-6e0fbf281a-a0c3b94346.zip index 22a51e35a1..edc7da3b25 100644 Binary files a/.yarn/cache/@fullcalendar-bootstrap5-npm-6.1.9-ef68c3c094-1d6168fafc.zip and b/.yarn/cache/@fullcalendar-bootstrap5-npm-6.1.11-6e0fbf281a-a0c3b94346.zip differ diff --git a/.yarn/cache/@fullcalendar-core-npm-6.1.11-ae049c8ace-0078a6f96b.zip b/.yarn/cache/@fullcalendar-core-npm-6.1.11-ae049c8ace-0078a6f96b.zip new file mode 100644 index 0000000000..c9eee67d63 Binary files /dev/null and b/.yarn/cache/@fullcalendar-core-npm-6.1.11-ae049c8ace-0078a6f96b.zip differ diff --git a/.yarn/cache/@fullcalendar-core-npm-6.1.9-b4da84d4b8-836db3e40c.zip b/.yarn/cache/@fullcalendar-core-npm-6.1.9-b4da84d4b8-836db3e40c.zip deleted file mode 100644 index 4e27bfed69..0000000000 Binary files a/.yarn/cache/@fullcalendar-core-npm-6.1.9-b4da84d4b8-836db3e40c.zip and /dev/null differ diff --git a/.yarn/cache/@fullcalendar-daygrid-npm-6.1.11-2187ca1b8f-6eb5606de5.zip b/.yarn/cache/@fullcalendar-daygrid-npm-6.1.11-2187ca1b8f-6eb5606de5.zip new file mode 100644 index 0000000000..3a7449a3a8 Binary files /dev/null and b/.yarn/cache/@fullcalendar-daygrid-npm-6.1.11-2187ca1b8f-6eb5606de5.zip differ diff --git a/.yarn/cache/@fullcalendar-daygrid-npm-6.1.9-4c0da59f84-3db55247c4.zip b/.yarn/cache/@fullcalendar-daygrid-npm-6.1.9-4c0da59f84-3db55247c4.zip deleted file mode 100644 index 5a0ad27421..0000000000 Binary files a/.yarn/cache/@fullcalendar-daygrid-npm-6.1.9-4c0da59f84-3db55247c4.zip and /dev/null differ diff --git a/.yarn/cache/@fullcalendar-icalendar-npm-6.1.9-92e390eda8-d47daf4ae0.zip b/.yarn/cache/@fullcalendar-icalendar-npm-6.1.11-73807e790d-4e6eff15a8.zip similarity index 53% rename from .yarn/cache/@fullcalendar-icalendar-npm-6.1.9-92e390eda8-d47daf4ae0.zip rename to .yarn/cache/@fullcalendar-icalendar-npm-6.1.11-73807e790d-4e6eff15a8.zip index 804279cbb4..861ed1b366 100644 Binary files a/.yarn/cache/@fullcalendar-icalendar-npm-6.1.9-92e390eda8-d47daf4ae0.zip and b/.yarn/cache/@fullcalendar-icalendar-npm-6.1.11-73807e790d-4e6eff15a8.zip differ diff --git a/.yarn/cache/@fullcalendar-interaction-npm-6.1.9-f729b81a3d-787111ea6f.zip b/.yarn/cache/@fullcalendar-interaction-npm-6.1.11-39630596c7-c67d4cfa0b.zip similarity index 57% rename from .yarn/cache/@fullcalendar-interaction-npm-6.1.9-f729b81a3d-787111ea6f.zip rename to .yarn/cache/@fullcalendar-interaction-npm-6.1.11-39630596c7-c67d4cfa0b.zip index 43132b57e8..b04343467b 100644 Binary files a/.yarn/cache/@fullcalendar-interaction-npm-6.1.9-f729b81a3d-787111ea6f.zip and b/.yarn/cache/@fullcalendar-interaction-npm-6.1.11-39630596c7-c67d4cfa0b.zip differ diff --git a/.yarn/cache/@fullcalendar-list-npm-6.1.9-f76695c5ab-978dd54b71.zip b/.yarn/cache/@fullcalendar-list-npm-6.1.11-8f1846f302-84a8cd6e63.zip similarity index 53% rename from .yarn/cache/@fullcalendar-list-npm-6.1.9-f76695c5ab-978dd54b71.zip rename to .yarn/cache/@fullcalendar-list-npm-6.1.11-8f1846f302-84a8cd6e63.zip index 177ec564d1..93cd34af81 100644 Binary files a/.yarn/cache/@fullcalendar-list-npm-6.1.9-f76695c5ab-978dd54b71.zip and b/.yarn/cache/@fullcalendar-list-npm-6.1.11-8f1846f302-84a8cd6e63.zip differ diff --git a/.yarn/cache/@fullcalendar-luxon3-npm-6.1.11-3e90656a71-8e7f45aab2.zip b/.yarn/cache/@fullcalendar-luxon3-npm-6.1.11-3e90656a71-8e7f45aab2.zip new file mode 100644 index 0000000000..6e717b3495 Binary files /dev/null and b/.yarn/cache/@fullcalendar-luxon3-npm-6.1.11-3e90656a71-8e7f45aab2.zip differ diff --git a/.yarn/cache/@fullcalendar-luxon3-npm-6.1.9-d79fc8f961-25122126e2.zip b/.yarn/cache/@fullcalendar-luxon3-npm-6.1.9-d79fc8f961-25122126e2.zip deleted file mode 100644 index 097ca2265a..0000000000 Binary files a/.yarn/cache/@fullcalendar-luxon3-npm-6.1.9-d79fc8f961-25122126e2.zip and /dev/null differ diff --git a/.yarn/cache/@fullcalendar-timegrid-npm-6.1.11-1d43455bfd-4a11e6dd90.zip b/.yarn/cache/@fullcalendar-timegrid-npm-6.1.11-1d43455bfd-4a11e6dd90.zip new file mode 100644 index 0000000000..917beeda69 Binary files /dev/null and b/.yarn/cache/@fullcalendar-timegrid-npm-6.1.11-1d43455bfd-4a11e6dd90.zip differ diff --git a/.yarn/cache/@fullcalendar-timegrid-npm-6.1.9-b227fefa80-8c12a508f7.zip b/.yarn/cache/@fullcalendar-timegrid-npm-6.1.9-b227fefa80-8c12a508f7.zip deleted file mode 100644 index 5d92af9ff1..0000000000 Binary files a/.yarn/cache/@fullcalendar-timegrid-npm-6.1.9-b227fefa80-8c12a508f7.zip and /dev/null differ diff --git a/.yarn/cache/@fullcalendar-vue3-npm-6.1.9-3c150e259d-2c1c0fbe72.zip b/.yarn/cache/@fullcalendar-vue3-npm-6.1.11-f6b8b48da4-5891a596e9.zip similarity index 91% rename from .yarn/cache/@fullcalendar-vue3-npm-6.1.9-3c150e259d-2c1c0fbe72.zip rename to .yarn/cache/@fullcalendar-vue3-npm-6.1.11-f6b8b48da4-5891a596e9.zip index 0ca254a0d6..3054aa761f 100644 Binary files a/.yarn/cache/@fullcalendar-vue3-npm-6.1.9-3c150e259d-2c1c0fbe72.zip and b/.yarn/cache/@fullcalendar-vue3-npm-6.1.11-f6b8b48da4-5891a596e9.zip differ diff --git a/.yarn/cache/@humanwhocodes-config-array-npm-0.11.13-12314014f2-f8ea57b0d7.zip b/.yarn/cache/@humanwhocodes-config-array-npm-0.11.14-94a02fcc87-861ccce9ea.zip similarity index 53% rename from .yarn/cache/@humanwhocodes-config-array-npm-0.11.13-12314014f2-f8ea57b0d7.zip rename to .yarn/cache/@humanwhocodes-config-array-npm-0.11.14-94a02fcc87-861ccce9ea.zip index 4a8665e835..166fee4b82 100644 Binary files a/.yarn/cache/@humanwhocodes-config-array-npm-0.11.13-12314014f2-f8ea57b0d7.zip and b/.yarn/cache/@humanwhocodes-config-array-npm-0.11.14-94a02fcc87-861ccce9ea.zip differ diff --git a/.yarn/cache/@humanwhocodes-object-schema-npm-2.0.1-c23364bbfc-24929487b1.zip b/.yarn/cache/@humanwhocodes-object-schema-npm-2.0.2-77b42018f9-2fc1150336.zip similarity index 64% rename from .yarn/cache/@humanwhocodes-object-schema-npm-2.0.1-c23364bbfc-24929487b1.zip rename to .yarn/cache/@humanwhocodes-object-schema-npm-2.0.2-77b42018f9-2fc1150336.zip index ddd36cec8c..cf6847cf44 100644 Binary files a/.yarn/cache/@humanwhocodes-object-schema-npm-2.0.1-c23364bbfc-24929487b1.zip and b/.yarn/cache/@humanwhocodes-object-schema-npm-2.0.2-77b42018f9-2fc1150336.zip differ diff --git a/.yarn/cache/@parcel-bundler-default-npm-2.10.0-bf1aa01515-58d3619928.zip b/.yarn/cache/@parcel-bundler-default-npm-2.10.0-bf1aa01515-58d3619928.zip deleted file mode 100644 index 302219eaa0..0000000000 Binary files a/.yarn/cache/@parcel-bundler-default-npm-2.10.0-bf1aa01515-58d3619928.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-bundler-default-npm-2.12.0-9ba57d919c-f211a76f55.zip b/.yarn/cache/@parcel-bundler-default-npm-2.12.0-9ba57d919c-f211a76f55.zip new file mode 100644 index 0000000000..024e036391 Binary files /dev/null and b/.yarn/cache/@parcel-bundler-default-npm-2.12.0-9ba57d919c-f211a76f55.zip differ diff --git a/.yarn/cache/@parcel-cache-npm-2.10.0-37f1f83d32-209d474abd.zip b/.yarn/cache/@parcel-cache-npm-2.10.0-37f1f83d32-209d474abd.zip deleted file mode 100644 index 271d9d3f6f..0000000000 Binary files a/.yarn/cache/@parcel-cache-npm-2.10.0-37f1f83d32-209d474abd.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-cache-npm-2.12.0-3389909f2c-a45e799809.zip b/.yarn/cache/@parcel-cache-npm-2.12.0-3389909f2c-a45e799809.zip new file mode 100644 index 0000000000..a358668eb7 Binary files /dev/null and b/.yarn/cache/@parcel-cache-npm-2.12.0-3389909f2c-a45e799809.zip differ diff --git a/.yarn/cache/@parcel-codeframe-npm-2.10.0-e8aa1b4ecc-d87b17d3ce.zip b/.yarn/cache/@parcel-codeframe-npm-2.12.0-aa8027940e-265c4d7ebe.zip similarity index 99% rename from .yarn/cache/@parcel-codeframe-npm-2.10.0-e8aa1b4ecc-d87b17d3ce.zip rename to .yarn/cache/@parcel-codeframe-npm-2.12.0-aa8027940e-265c4d7ebe.zip index 9058e68333..f4239d8ba7 100644 Binary files a/.yarn/cache/@parcel-codeframe-npm-2.10.0-e8aa1b4ecc-d87b17d3ce.zip and b/.yarn/cache/@parcel-codeframe-npm-2.12.0-aa8027940e-265c4d7ebe.zip differ diff --git a/.yarn/cache/@parcel-compressor-raw-npm-2.10.0-961e5d9fe0-043fca0ecb.zip b/.yarn/cache/@parcel-compressor-raw-npm-2.10.0-961e5d9fe0-043fca0ecb.zip deleted file mode 100644 index e6333b4084..0000000000 Binary files a/.yarn/cache/@parcel-compressor-raw-npm-2.10.0-961e5d9fe0-043fca0ecb.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-compressor-raw-npm-2.12.0-19f313c172-16c56704f3.zip b/.yarn/cache/@parcel-compressor-raw-npm-2.12.0-19f313c172-16c56704f3.zip new file mode 100644 index 0000000000..da57625381 Binary files /dev/null and b/.yarn/cache/@parcel-compressor-raw-npm-2.12.0-19f313c172-16c56704f3.zip differ diff --git a/.yarn/cache/@parcel-config-default-npm-2.10.0-2a1fbdf24b-d780d05021.zip b/.yarn/cache/@parcel-config-default-npm-2.12.0-aefd3c699e-72877c5dc4.zip similarity index 66% rename from .yarn/cache/@parcel-config-default-npm-2.10.0-2a1fbdf24b-d780d05021.zip rename to .yarn/cache/@parcel-config-default-npm-2.12.0-aefd3c699e-72877c5dc4.zip index d5a1840883..a4934d017e 100644 Binary files a/.yarn/cache/@parcel-config-default-npm-2.10.0-2a1fbdf24b-d780d05021.zip and b/.yarn/cache/@parcel-config-default-npm-2.12.0-aefd3c699e-72877c5dc4.zip differ diff --git a/.yarn/cache/@parcel-core-npm-2.10.0-59eaeeba7a-c59c2971ea.zip b/.yarn/cache/@parcel-core-npm-2.10.0-59eaeeba7a-c59c2971ea.zip deleted file mode 100644 index 95a14ae902..0000000000 Binary files a/.yarn/cache/@parcel-core-npm-2.10.0-59eaeeba7a-c59c2971ea.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-core-npm-2.12.0-8f08b883d4-5bf6746308.zip b/.yarn/cache/@parcel-core-npm-2.12.0-8f08b883d4-5bf6746308.zip new file mode 100644 index 0000000000..42c39ebe36 Binary files /dev/null and b/.yarn/cache/@parcel-core-npm-2.12.0-8f08b883d4-5bf6746308.zip differ diff --git a/.yarn/cache/@parcel-diagnostic-npm-2.10.0-1e389b369e-45c606ca52.zip b/.yarn/cache/@parcel-diagnostic-npm-2.12.0-6e89ddad28-a4b918c1a0.zip similarity index 73% rename from .yarn/cache/@parcel-diagnostic-npm-2.10.0-1e389b369e-45c606ca52.zip rename to .yarn/cache/@parcel-diagnostic-npm-2.12.0-6e89ddad28-a4b918c1a0.zip index c16d0b42f4..a8e890bf5c 100644 Binary files a/.yarn/cache/@parcel-diagnostic-npm-2.10.0-1e389b369e-45c606ca52.zip and b/.yarn/cache/@parcel-diagnostic-npm-2.12.0-6e89ddad28-a4b918c1a0.zip differ diff --git a/.yarn/cache/@parcel-events-npm-2.10.0-da42a4afa6-1d21cd4186.zip b/.yarn/cache/@parcel-events-npm-2.12.0-e6eff18c8c-136a8a2921.zip similarity index 94% rename from .yarn/cache/@parcel-events-npm-2.10.0-da42a4afa6-1d21cd4186.zip rename to .yarn/cache/@parcel-events-npm-2.12.0-e6eff18c8c-136a8a2921.zip index cf89807381..b806eb99ac 100644 Binary files a/.yarn/cache/@parcel-events-npm-2.10.0-da42a4afa6-1d21cd4186.zip and b/.yarn/cache/@parcel-events-npm-2.12.0-e6eff18c8c-136a8a2921.zip differ diff --git a/.yarn/cache/@parcel-fs-npm-2.10.0-c959567f0f-10faae481c.zip b/.yarn/cache/@parcel-fs-npm-2.10.0-c959567f0f-10faae481c.zip deleted file mode 100644 index 0062d48c08..0000000000 Binary files a/.yarn/cache/@parcel-fs-npm-2.10.0-c959567f0f-10faae481c.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-fs-npm-2.12.0-3c46842e62-43d454d55d.zip b/.yarn/cache/@parcel-fs-npm-2.12.0-3c46842e62-43d454d55d.zip new file mode 100644 index 0000000000..52cbc5f7f1 Binary files /dev/null and b/.yarn/cache/@parcel-fs-npm-2.12.0-3c46842e62-43d454d55d.zip differ diff --git a/.yarn/cache/@parcel-graph-npm-3.0.0-9001abfefc-0a9d5017f6.zip b/.yarn/cache/@parcel-graph-npm-3.0.0-9001abfefc-0a9d5017f6.zip deleted file mode 100644 index 164f2ff077..0000000000 Binary files a/.yarn/cache/@parcel-graph-npm-3.0.0-9001abfefc-0a9d5017f6.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-graph-npm-3.2.0-92821d4289-b4d31624fc.zip b/.yarn/cache/@parcel-graph-npm-3.2.0-92821d4289-b4d31624fc.zip new file mode 100644 index 0000000000..27f3718928 Binary files /dev/null and b/.yarn/cache/@parcel-graph-npm-3.2.0-92821d4289-b4d31624fc.zip differ diff --git a/.yarn/cache/@parcel-logger-npm-2.10.0-41ac90e34c-52d0b5331d.zip b/.yarn/cache/@parcel-logger-npm-2.10.0-41ac90e34c-52d0b5331d.zip deleted file mode 100644 index d7df80c7bf..0000000000 Binary files a/.yarn/cache/@parcel-logger-npm-2.10.0-41ac90e34c-52d0b5331d.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-logger-npm-2.12.0-7d2f85a906-be3fe9d9ea.zip b/.yarn/cache/@parcel-logger-npm-2.12.0-7d2f85a906-be3fe9d9ea.zip new file mode 100644 index 0000000000..7231e4c65d Binary files /dev/null and b/.yarn/cache/@parcel-logger-npm-2.12.0-7d2f85a906-be3fe9d9ea.zip differ diff --git a/.yarn/cache/@parcel-markdown-ansi-npm-2.10.0-4dd4da44f3-35e2d07ec8.zip b/.yarn/cache/@parcel-markdown-ansi-npm-2.12.0-6b0fe453df-850ee665d9.zip similarity index 64% rename from .yarn/cache/@parcel-markdown-ansi-npm-2.10.0-4dd4da44f3-35e2d07ec8.zip rename to .yarn/cache/@parcel-markdown-ansi-npm-2.12.0-6b0fe453df-850ee665d9.zip index f5a836e3cc..22582b46fa 100644 Binary files a/.yarn/cache/@parcel-markdown-ansi-npm-2.10.0-4dd4da44f3-35e2d07ec8.zip and b/.yarn/cache/@parcel-markdown-ansi-npm-2.12.0-6b0fe453df-850ee665d9.zip differ diff --git a/.yarn/cache/@parcel-namer-default-npm-2.10.0-4b82db40fd-f2a32096d1.zip b/.yarn/cache/@parcel-namer-default-npm-2.10.0-4b82db40fd-f2a32096d1.zip deleted file mode 100644 index 46d6a52498..0000000000 Binary files a/.yarn/cache/@parcel-namer-default-npm-2.10.0-4b82db40fd-f2a32096d1.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-namer-default-npm-2.12.0-28980cfd47-dc92ec0945.zip b/.yarn/cache/@parcel-namer-default-npm-2.12.0-28980cfd47-dc92ec0945.zip new file mode 100644 index 0000000000..7db7fb405c Binary files /dev/null and b/.yarn/cache/@parcel-namer-default-npm-2.12.0-28980cfd47-dc92ec0945.zip differ diff --git a/.yarn/cache/@parcel-node-resolver-core-npm-3.1.0-9c9ff3ab8b-dcdd39bc6a.zip b/.yarn/cache/@parcel-node-resolver-core-npm-3.1.0-9c9ff3ab8b-dcdd39bc6a.zip deleted file mode 100644 index 33ee38431b..0000000000 Binary files a/.yarn/cache/@parcel-node-resolver-core-npm-3.1.0-9c9ff3ab8b-dcdd39bc6a.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-node-resolver-core-npm-3.3.0-53804df663-acc3721678.zip b/.yarn/cache/@parcel-node-resolver-core-npm-3.3.0-53804df663-acc3721678.zip new file mode 100644 index 0000000000..76a69962a6 Binary files /dev/null and b/.yarn/cache/@parcel-node-resolver-core-npm-3.3.0-53804df663-acc3721678.zip differ diff --git a/.yarn/cache/@parcel-optimizer-css-npm-2.10.0-dbd5825b4e-ea15989512.zip b/.yarn/cache/@parcel-optimizer-css-npm-2.10.0-dbd5825b4e-ea15989512.zip deleted file mode 100644 index 9956afb109..0000000000 Binary files a/.yarn/cache/@parcel-optimizer-css-npm-2.10.0-dbd5825b4e-ea15989512.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-optimizer-css-npm-2.12.0-f95bd4d060-abcdf58c29.zip b/.yarn/cache/@parcel-optimizer-css-npm-2.12.0-f95bd4d060-abcdf58c29.zip new file mode 100644 index 0000000000..f1c61749b9 Binary files /dev/null and b/.yarn/cache/@parcel-optimizer-css-npm-2.12.0-f95bd4d060-abcdf58c29.zip differ diff --git a/.yarn/cache/@parcel-optimizer-data-url-npm-2.10.0-700cb5aab6-ec9530be83.zip b/.yarn/cache/@parcel-optimizer-data-url-npm-2.10.0-700cb5aab6-ec9530be83.zip deleted file mode 100644 index 39c8d2b426..0000000000 Binary files a/.yarn/cache/@parcel-optimizer-data-url-npm-2.10.0-700cb5aab6-ec9530be83.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-optimizer-data-url-npm-2.12.0-dad3731170-0397293961.zip b/.yarn/cache/@parcel-optimizer-data-url-npm-2.12.0-dad3731170-0397293961.zip new file mode 100644 index 0000000000..28497d3327 Binary files /dev/null and b/.yarn/cache/@parcel-optimizer-data-url-npm-2.12.0-dad3731170-0397293961.zip differ diff --git a/.yarn/cache/@parcel-optimizer-htmlnano-npm-2.10.0-ee0243765c-1f6de13022.zip b/.yarn/cache/@parcel-optimizer-htmlnano-npm-2.10.0-ee0243765c-1f6de13022.zip deleted file mode 100644 index 7b7bff73e7..0000000000 Binary files a/.yarn/cache/@parcel-optimizer-htmlnano-npm-2.10.0-ee0243765c-1f6de13022.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-optimizer-htmlnano-npm-2.12.0-cdd2835c12-64e571f56f.zip b/.yarn/cache/@parcel-optimizer-htmlnano-npm-2.12.0-cdd2835c12-64e571f56f.zip new file mode 100644 index 0000000000..4089a870fb Binary files /dev/null and b/.yarn/cache/@parcel-optimizer-htmlnano-npm-2.12.0-cdd2835c12-64e571f56f.zip differ diff --git a/.yarn/cache/@parcel-optimizer-image-npm-2.10.0-a581b60cbd-94d5db2837.zip b/.yarn/cache/@parcel-optimizer-image-npm-2.10.0-a581b60cbd-94d5db2837.zip deleted file mode 100644 index df6e165df9..0000000000 Binary files a/.yarn/cache/@parcel-optimizer-image-npm-2.10.0-a581b60cbd-94d5db2837.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-optimizer-image-npm-2.12.0-4cbc56f72d-7d28379bf1.zip b/.yarn/cache/@parcel-optimizer-image-npm-2.12.0-4cbc56f72d-7d28379bf1.zip new file mode 100644 index 0000000000..8b0a44e756 Binary files /dev/null and b/.yarn/cache/@parcel-optimizer-image-npm-2.12.0-4cbc56f72d-7d28379bf1.zip differ diff --git a/.yarn/cache/@parcel-optimizer-svgo-npm-2.10.0-154d938969-7201c63222.zip b/.yarn/cache/@parcel-optimizer-svgo-npm-2.10.0-154d938969-7201c63222.zip deleted file mode 100644 index ba2a5ca11a..0000000000 Binary files a/.yarn/cache/@parcel-optimizer-svgo-npm-2.10.0-154d938969-7201c63222.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-optimizer-svgo-npm-2.12.0-08c0f1b17f-d3a4d2de9f.zip b/.yarn/cache/@parcel-optimizer-svgo-npm-2.12.0-08c0f1b17f-d3a4d2de9f.zip new file mode 100644 index 0000000000..441bead99b Binary files /dev/null and b/.yarn/cache/@parcel-optimizer-svgo-npm-2.12.0-08c0f1b17f-d3a4d2de9f.zip differ diff --git a/.yarn/cache/@parcel-optimizer-swc-npm-2.10.0-caf3bb9c02-1fe68ee6ff.zip b/.yarn/cache/@parcel-optimizer-swc-npm-2.10.0-caf3bb9c02-1fe68ee6ff.zip deleted file mode 100644 index ac83217658..0000000000 Binary files a/.yarn/cache/@parcel-optimizer-swc-npm-2.10.0-caf3bb9c02-1fe68ee6ff.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-optimizer-swc-npm-2.12.0-fb535e4283-0b7fdf3df1.zip b/.yarn/cache/@parcel-optimizer-swc-npm-2.12.0-fb535e4283-0b7fdf3df1.zip new file mode 100644 index 0000000000..8b137cf673 Binary files /dev/null and b/.yarn/cache/@parcel-optimizer-swc-npm-2.12.0-fb535e4283-0b7fdf3df1.zip differ diff --git a/.yarn/cache/@parcel-package-manager-npm-2.10.0-4f4a39adee-7c4a95d9df.zip b/.yarn/cache/@parcel-package-manager-npm-2.10.0-4f4a39adee-7c4a95d9df.zip deleted file mode 100644 index 77ba89e562..0000000000 Binary files a/.yarn/cache/@parcel-package-manager-npm-2.10.0-4f4a39adee-7c4a95d9df.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-package-manager-npm-2.12.0-fc90aacf70-a517e9efe1.zip b/.yarn/cache/@parcel-package-manager-npm-2.12.0-fc90aacf70-a517e9efe1.zip new file mode 100644 index 0000000000..1e757bdf2f Binary files /dev/null and b/.yarn/cache/@parcel-package-manager-npm-2.12.0-fc90aacf70-a517e9efe1.zip differ diff --git a/.yarn/cache/@parcel-packager-css-npm-2.10.0-cb31a968a8-11bf4cae4c.zip b/.yarn/cache/@parcel-packager-css-npm-2.10.0-cb31a968a8-11bf4cae4c.zip deleted file mode 100644 index 71fc7e92a4..0000000000 Binary files a/.yarn/cache/@parcel-packager-css-npm-2.10.0-cb31a968a8-11bf4cae4c.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-packager-css-npm-2.12.0-b1c27a8323-684aaa1d85.zip b/.yarn/cache/@parcel-packager-css-npm-2.12.0-b1c27a8323-684aaa1d85.zip new file mode 100644 index 0000000000..4cf7815f57 Binary files /dev/null and b/.yarn/cache/@parcel-packager-css-npm-2.12.0-b1c27a8323-684aaa1d85.zip differ diff --git a/.yarn/cache/@parcel-packager-html-npm-2.10.0-d6f71e7e36-8dfd86e7d6.zip b/.yarn/cache/@parcel-packager-html-npm-2.12.0-ad361b1265-ee558ad616.zip similarity index 50% rename from .yarn/cache/@parcel-packager-html-npm-2.10.0-d6f71e7e36-8dfd86e7d6.zip rename to .yarn/cache/@parcel-packager-html-npm-2.12.0-ad361b1265-ee558ad616.zip index d3c871d581..989402a62c 100644 Binary files a/.yarn/cache/@parcel-packager-html-npm-2.10.0-d6f71e7e36-8dfd86e7d6.zip and b/.yarn/cache/@parcel-packager-html-npm-2.12.0-ad361b1265-ee558ad616.zip differ diff --git a/.yarn/cache/@parcel-packager-js-npm-2.10.0-f84ec4cc7b-9b62598864.zip b/.yarn/cache/@parcel-packager-js-npm-2.12.0-093e3200cd-2189b7ff15.zip similarity index 59% rename from .yarn/cache/@parcel-packager-js-npm-2.10.0-f84ec4cc7b-9b62598864.zip rename to .yarn/cache/@parcel-packager-js-npm-2.12.0-093e3200cd-2189b7ff15.zip index 9084795b65..461ec50d28 100644 Binary files a/.yarn/cache/@parcel-packager-js-npm-2.10.0-f84ec4cc7b-9b62598864.zip and b/.yarn/cache/@parcel-packager-js-npm-2.12.0-093e3200cd-2189b7ff15.zip differ diff --git a/.yarn/cache/@parcel-packager-raw-npm-2.10.0-01ef1b8e3e-492fe07ae5.zip b/.yarn/cache/@parcel-packager-raw-npm-2.10.0-01ef1b8e3e-492fe07ae5.zip deleted file mode 100644 index 132ebb4043..0000000000 Binary files a/.yarn/cache/@parcel-packager-raw-npm-2.10.0-01ef1b8e3e-492fe07ae5.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-packager-raw-npm-2.12.0-b7f15635f8-39ce2fc7ae.zip b/.yarn/cache/@parcel-packager-raw-npm-2.12.0-b7f15635f8-39ce2fc7ae.zip new file mode 100644 index 0000000000..e27b5ed1e3 Binary files /dev/null and b/.yarn/cache/@parcel-packager-raw-npm-2.12.0-b7f15635f8-39ce2fc7ae.zip differ diff --git a/.yarn/cache/@parcel-packager-svg-npm-2.10.0-22326715bd-f49d7f3b88.zip b/.yarn/cache/@parcel-packager-svg-npm-2.10.0-22326715bd-f49d7f3b88.zip deleted file mode 100644 index c3031c1920..0000000000 Binary files a/.yarn/cache/@parcel-packager-svg-npm-2.10.0-22326715bd-f49d7f3b88.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-packager-svg-npm-2.12.0-fa921ce522-436ac9ea39.zip b/.yarn/cache/@parcel-packager-svg-npm-2.12.0-fa921ce522-436ac9ea39.zip new file mode 100644 index 0000000000..f3d37303b0 Binary files /dev/null and b/.yarn/cache/@parcel-packager-svg-npm-2.12.0-fa921ce522-436ac9ea39.zip differ diff --git a/.yarn/cache/@parcel-packager-wasm-npm-2.10.0-b1d2cd8f88-d9a13eb838.zip b/.yarn/cache/@parcel-packager-wasm-npm-2.12.0-ec551a9e29-a10e1cd988.zip similarity index 76% rename from .yarn/cache/@parcel-packager-wasm-npm-2.10.0-b1d2cd8f88-d9a13eb838.zip rename to .yarn/cache/@parcel-packager-wasm-npm-2.12.0-ec551a9e29-a10e1cd988.zip index 2cde1a739e..5b569f2004 100644 Binary files a/.yarn/cache/@parcel-packager-wasm-npm-2.10.0-b1d2cd8f88-d9a13eb838.zip and b/.yarn/cache/@parcel-packager-wasm-npm-2.12.0-ec551a9e29-a10e1cd988.zip differ diff --git a/.yarn/cache/@parcel-plugin-npm-2.10.0-efbc58a209-e13ba6e7e5.zip b/.yarn/cache/@parcel-plugin-npm-2.12.0-947dec85d3-0b52f1dd06.zip similarity index 77% rename from .yarn/cache/@parcel-plugin-npm-2.10.0-efbc58a209-e13ba6e7e5.zip rename to .yarn/cache/@parcel-plugin-npm-2.12.0-947dec85d3-0b52f1dd06.zip index 0c18974840..667d7230e6 100644 Binary files a/.yarn/cache/@parcel-plugin-npm-2.10.0-efbc58a209-e13ba6e7e5.zip and b/.yarn/cache/@parcel-plugin-npm-2.12.0-947dec85d3-0b52f1dd06.zip differ diff --git a/.yarn/cache/@parcel-profiler-npm-2.10.0-b1ba499bc1-78d545edb7.zip b/.yarn/cache/@parcel-profiler-npm-2.10.0-b1ba499bc1-78d545edb7.zip deleted file mode 100644 index 7a39442c82..0000000000 Binary files a/.yarn/cache/@parcel-profiler-npm-2.10.0-b1ba499bc1-78d545edb7.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-profiler-npm-2.12.0-69720a23ab-b683b74e10.zip b/.yarn/cache/@parcel-profiler-npm-2.12.0-69720a23ab-b683b74e10.zip new file mode 100644 index 0000000000..1cacc84571 Binary files /dev/null and b/.yarn/cache/@parcel-profiler-npm-2.12.0-69720a23ab-b683b74e10.zip differ diff --git a/.yarn/cache/@parcel-reporter-cli-npm-2.10.0-083fc2f2d6-0137a91e45.zip b/.yarn/cache/@parcel-reporter-cli-npm-2.10.0-083fc2f2d6-0137a91e45.zip deleted file mode 100644 index cafce4c242..0000000000 Binary files a/.yarn/cache/@parcel-reporter-cli-npm-2.10.0-083fc2f2d6-0137a91e45.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-reporter-cli-npm-2.12.0-b3e4c5fe19-8cc524fa15.zip b/.yarn/cache/@parcel-reporter-cli-npm-2.12.0-b3e4c5fe19-8cc524fa15.zip new file mode 100644 index 0000000000..f6e625d396 Binary files /dev/null and b/.yarn/cache/@parcel-reporter-cli-npm-2.12.0-b3e4c5fe19-8cc524fa15.zip differ diff --git a/.yarn/cache/@parcel-reporter-dev-server-npm-2.10.0-2f19cb846e-e72fd6ec09.zip b/.yarn/cache/@parcel-reporter-dev-server-npm-2.10.0-2f19cb846e-e72fd6ec09.zip deleted file mode 100644 index 655d45cf19..0000000000 Binary files a/.yarn/cache/@parcel-reporter-dev-server-npm-2.10.0-2f19cb846e-e72fd6ec09.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-reporter-dev-server-npm-2.12.0-aed1d2c68c-43957b4656.zip b/.yarn/cache/@parcel-reporter-dev-server-npm-2.12.0-aed1d2c68c-43957b4656.zip new file mode 100644 index 0000000000..f1fb1818e9 Binary files /dev/null and b/.yarn/cache/@parcel-reporter-dev-server-npm-2.12.0-aed1d2c68c-43957b4656.zip differ diff --git a/.yarn/cache/@parcel-reporter-tracer-npm-2.10.0-184a89e262-0f8249b998.zip b/.yarn/cache/@parcel-reporter-tracer-npm-2.10.0-184a89e262-0f8249b998.zip deleted file mode 100644 index 533f83cc16..0000000000 Binary files a/.yarn/cache/@parcel-reporter-tracer-npm-2.10.0-184a89e262-0f8249b998.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-reporter-tracer-npm-2.12.0-5cec9ab2d5-24cddacd19.zip b/.yarn/cache/@parcel-reporter-tracer-npm-2.12.0-5cec9ab2d5-24cddacd19.zip new file mode 100644 index 0000000000..2196f5407c Binary files /dev/null and b/.yarn/cache/@parcel-reporter-tracer-npm-2.12.0-5cec9ab2d5-24cddacd19.zip differ diff --git a/.yarn/cache/@parcel-resolver-default-npm-2.10.0-ca49f01a75-c82e2d3c4b.zip b/.yarn/cache/@parcel-resolver-default-npm-2.10.0-ca49f01a75-c82e2d3c4b.zip deleted file mode 100644 index 39ac48c80c..0000000000 Binary files a/.yarn/cache/@parcel-resolver-default-npm-2.10.0-ca49f01a75-c82e2d3c4b.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-resolver-default-npm-2.12.0-8da790891c-f3652eea09.zip b/.yarn/cache/@parcel-resolver-default-npm-2.12.0-8da790891c-f3652eea09.zip new file mode 100644 index 0000000000..8022d04651 Binary files /dev/null and b/.yarn/cache/@parcel-resolver-default-npm-2.12.0-8da790891c-f3652eea09.zip differ diff --git a/.yarn/cache/@parcel-runtime-browser-hmr-npm-2.10.0-c6b7773a09-12928462c8.zip b/.yarn/cache/@parcel-runtime-browser-hmr-npm-2.10.0-c6b7773a09-12928462c8.zip deleted file mode 100644 index 7f1d968b8a..0000000000 Binary files a/.yarn/cache/@parcel-runtime-browser-hmr-npm-2.10.0-c6b7773a09-12928462c8.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-runtime-browser-hmr-npm-2.12.0-6f0da66673-bbba57ecee.zip b/.yarn/cache/@parcel-runtime-browser-hmr-npm-2.12.0-6f0da66673-bbba57ecee.zip new file mode 100644 index 0000000000..f71de2152b Binary files /dev/null and b/.yarn/cache/@parcel-runtime-browser-hmr-npm-2.12.0-6f0da66673-bbba57ecee.zip differ diff --git a/.yarn/cache/@parcel-runtime-js-npm-2.10.0-6b4cf1576c-3bbd64c5b9.zip b/.yarn/cache/@parcel-runtime-js-npm-2.12.0-e21acc0f42-6afa3e7eb2.zip similarity index 76% rename from .yarn/cache/@parcel-runtime-js-npm-2.10.0-6b4cf1576c-3bbd64c5b9.zip rename to .yarn/cache/@parcel-runtime-js-npm-2.12.0-e21acc0f42-6afa3e7eb2.zip index 10fc3360d3..be9c7d7e4b 100644 Binary files a/.yarn/cache/@parcel-runtime-js-npm-2.10.0-6b4cf1576c-3bbd64c5b9.zip and b/.yarn/cache/@parcel-runtime-js-npm-2.12.0-e21acc0f42-6afa3e7eb2.zip differ diff --git a/.yarn/cache/@parcel-runtime-react-refresh-npm-2.10.0-b1f6c62bdf-dc567474a1.zip b/.yarn/cache/@parcel-runtime-react-refresh-npm-2.10.0-b1f6c62bdf-dc567474a1.zip deleted file mode 100644 index 75e403b9dd..0000000000 Binary files a/.yarn/cache/@parcel-runtime-react-refresh-npm-2.10.0-b1f6c62bdf-dc567474a1.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-runtime-react-refresh-npm-2.12.0-2b09615691-41aee9a874.zip b/.yarn/cache/@parcel-runtime-react-refresh-npm-2.12.0-2b09615691-41aee9a874.zip new file mode 100644 index 0000000000..8dc8e5281c Binary files /dev/null and b/.yarn/cache/@parcel-runtime-react-refresh-npm-2.12.0-2b09615691-41aee9a874.zip differ diff --git a/.yarn/cache/@parcel-runtime-service-worker-npm-2.10.0-3ca99a5366-d0bfd113b9.zip b/.yarn/cache/@parcel-runtime-service-worker-npm-2.10.0-3ca99a5366-d0bfd113b9.zip deleted file mode 100644 index 8602ad88a3..0000000000 Binary files a/.yarn/cache/@parcel-runtime-service-worker-npm-2.10.0-3ca99a5366-d0bfd113b9.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-runtime-service-worker-npm-2.12.0-7d227ff0bf-c71246428e.zip b/.yarn/cache/@parcel-runtime-service-worker-npm-2.12.0-7d227ff0bf-c71246428e.zip new file mode 100644 index 0000000000..18682c22ae Binary files /dev/null and b/.yarn/cache/@parcel-runtime-service-worker-npm-2.12.0-7d227ff0bf-c71246428e.zip differ diff --git a/.yarn/cache/@parcel-rust-npm-2.10.0-99038406b0-466a78d27d.zip b/.yarn/cache/@parcel-rust-npm-2.12.0-0cf943f3e5-51c5b67b9e.zip similarity index 72% rename from .yarn/cache/@parcel-rust-npm-2.10.0-99038406b0-466a78d27d.zip rename to .yarn/cache/@parcel-rust-npm-2.12.0-0cf943f3e5-51c5b67b9e.zip index 22f9773a93..d5fe4206c9 100644 Binary files a/.yarn/cache/@parcel-rust-npm-2.10.0-99038406b0-466a78d27d.zip and b/.yarn/cache/@parcel-rust-npm-2.12.0-0cf943f3e5-51c5b67b9e.zip differ diff --git a/.yarn/cache/@parcel-transformer-babel-npm-2.10.0-fb74ad8c73-fd64092c9c.zip b/.yarn/cache/@parcel-transformer-babel-npm-2.12.0-953de52432-b8c457c0be.zip similarity index 87% rename from .yarn/cache/@parcel-transformer-babel-npm-2.10.0-fb74ad8c73-fd64092c9c.zip rename to .yarn/cache/@parcel-transformer-babel-npm-2.12.0-953de52432-b8c457c0be.zip index df186d3d17..9286325c9e 100644 Binary files a/.yarn/cache/@parcel-transformer-babel-npm-2.10.0-fb74ad8c73-fd64092c9c.zip and b/.yarn/cache/@parcel-transformer-babel-npm-2.12.0-953de52432-b8c457c0be.zip differ diff --git a/.yarn/cache/@parcel-transformer-css-npm-2.10.0-4fc35c8005-acc26e9b3d.zip b/.yarn/cache/@parcel-transformer-css-npm-2.10.0-4fc35c8005-acc26e9b3d.zip deleted file mode 100644 index 9ad2a3992a..0000000000 Binary files a/.yarn/cache/@parcel-transformer-css-npm-2.10.0-4fc35c8005-acc26e9b3d.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-transformer-css-npm-2.12.0-24ddc31ae3-3a6f16321d.zip b/.yarn/cache/@parcel-transformer-css-npm-2.12.0-24ddc31ae3-3a6f16321d.zip new file mode 100644 index 0000000000..f3e0520c71 Binary files /dev/null and b/.yarn/cache/@parcel-transformer-css-npm-2.12.0-24ddc31ae3-3a6f16321d.zip differ diff --git a/.yarn/cache/@parcel-transformer-html-npm-2.10.0-b6d2228044-f28e0d3606.zip b/.yarn/cache/@parcel-transformer-html-npm-2.12.0-be2b9ee40c-7fcfac62ca.zip similarity index 79% rename from .yarn/cache/@parcel-transformer-html-npm-2.10.0-b6d2228044-f28e0d3606.zip rename to .yarn/cache/@parcel-transformer-html-npm-2.12.0-be2b9ee40c-7fcfac62ca.zip index 5a84fa37da..3628f3f90d 100644 Binary files a/.yarn/cache/@parcel-transformer-html-npm-2.10.0-b6d2228044-f28e0d3606.zip and b/.yarn/cache/@parcel-transformer-html-npm-2.12.0-be2b9ee40c-7fcfac62ca.zip differ diff --git a/.yarn/cache/@parcel-transformer-image-npm-2.10.0-e63bd526ed-61a47d7d8e.zip b/.yarn/cache/@parcel-transformer-image-npm-2.12.0-53f04e21c0-0a1581eacc.zip similarity index 50% rename from .yarn/cache/@parcel-transformer-image-npm-2.10.0-e63bd526ed-61a47d7d8e.zip rename to .yarn/cache/@parcel-transformer-image-npm-2.12.0-53f04e21c0-0a1581eacc.zip index 18ec9cbd62..3a78e4e070 100644 Binary files a/.yarn/cache/@parcel-transformer-image-npm-2.10.0-e63bd526ed-61a47d7d8e.zip and b/.yarn/cache/@parcel-transformer-image-npm-2.12.0-53f04e21c0-0a1581eacc.zip differ diff --git a/.yarn/cache/@parcel-transformer-inline-string-npm-2.10.0-44c9b349db-618c919108.zip b/.yarn/cache/@parcel-transformer-inline-string-npm-2.10.0-44c9b349db-618c919108.zip deleted file mode 100644 index 636b8cafdc..0000000000 Binary files a/.yarn/cache/@parcel-transformer-inline-string-npm-2.10.0-44c9b349db-618c919108.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-transformer-inline-string-npm-2.12.0-a33f10bafa-5f63c08695.zip b/.yarn/cache/@parcel-transformer-inline-string-npm-2.12.0-a33f10bafa-5f63c08695.zip new file mode 100644 index 0000000000..0c4f3341c8 Binary files /dev/null and b/.yarn/cache/@parcel-transformer-inline-string-npm-2.12.0-a33f10bafa-5f63c08695.zip differ diff --git a/.yarn/cache/@parcel-transformer-js-npm-2.10.0-132e460926-e9944ce77c.zip b/.yarn/cache/@parcel-transformer-js-npm-2.10.0-132e460926-e9944ce77c.zip deleted file mode 100644 index ff6263c541..0000000000 Binary files a/.yarn/cache/@parcel-transformer-js-npm-2.10.0-132e460926-e9944ce77c.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-transformer-js-npm-2.12.0-404d54db18-b9fe4c887b.zip b/.yarn/cache/@parcel-transformer-js-npm-2.12.0-404d54db18-b9fe4c887b.zip new file mode 100644 index 0000000000..1ce667ac8d Binary files /dev/null and b/.yarn/cache/@parcel-transformer-js-npm-2.12.0-404d54db18-b9fe4c887b.zip differ diff --git a/.yarn/cache/@parcel-transformer-json-npm-2.10.0-5525143f86-9c7aceb8e6.zip b/.yarn/cache/@parcel-transformer-json-npm-2.10.0-5525143f86-9c7aceb8e6.zip deleted file mode 100644 index e821a47649..0000000000 Binary files a/.yarn/cache/@parcel-transformer-json-npm-2.10.0-5525143f86-9c7aceb8e6.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-transformer-json-npm-2.12.0-652d8d99d2-a711cb65a8.zip b/.yarn/cache/@parcel-transformer-json-npm-2.12.0-652d8d99d2-a711cb65a8.zip new file mode 100644 index 0000000000..926c01eb81 Binary files /dev/null and b/.yarn/cache/@parcel-transformer-json-npm-2.12.0-652d8d99d2-a711cb65a8.zip differ diff --git a/.yarn/cache/@parcel-transformer-postcss-npm-2.10.0-c1f60c708a-2e524bd513.zip b/.yarn/cache/@parcel-transformer-postcss-npm-2.12.0-f0cfb95fac-b210044a7f.zip similarity index 64% rename from .yarn/cache/@parcel-transformer-postcss-npm-2.10.0-c1f60c708a-2e524bd513.zip rename to .yarn/cache/@parcel-transformer-postcss-npm-2.12.0-f0cfb95fac-b210044a7f.zip index 5e78f70b2d..3bbacafa81 100644 Binary files a/.yarn/cache/@parcel-transformer-postcss-npm-2.10.0-c1f60c708a-2e524bd513.zip and b/.yarn/cache/@parcel-transformer-postcss-npm-2.12.0-f0cfb95fac-b210044a7f.zip differ diff --git a/.yarn/cache/@parcel-transformer-posthtml-npm-2.10.0-31d54ed3f0-7de343f0f9.zip b/.yarn/cache/@parcel-transformer-posthtml-npm-2.10.0-31d54ed3f0-7de343f0f9.zip deleted file mode 100644 index 5228dc9af8..0000000000 Binary files a/.yarn/cache/@parcel-transformer-posthtml-npm-2.10.0-31d54ed3f0-7de343f0f9.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-transformer-posthtml-npm-2.12.0-41c570db12-b62582ae7e.zip b/.yarn/cache/@parcel-transformer-posthtml-npm-2.12.0-41c570db12-b62582ae7e.zip new file mode 100644 index 0000000000..e912a09713 Binary files /dev/null and b/.yarn/cache/@parcel-transformer-posthtml-npm-2.12.0-41c570db12-b62582ae7e.zip differ diff --git a/.yarn/cache/@parcel-transformer-raw-npm-2.10.0-d7cd50f767-c7b1b9c6f7.zip b/.yarn/cache/@parcel-transformer-raw-npm-2.10.0-d7cd50f767-c7b1b9c6f7.zip deleted file mode 100644 index 3c536f6417..0000000000 Binary files a/.yarn/cache/@parcel-transformer-raw-npm-2.10.0-d7cd50f767-c7b1b9c6f7.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-transformer-raw-npm-2.12.0-bd2cb66ddf-de6681e2e7.zip b/.yarn/cache/@parcel-transformer-raw-npm-2.12.0-bd2cb66ddf-de6681e2e7.zip new file mode 100644 index 0000000000..40b7e2d3c4 Binary files /dev/null and b/.yarn/cache/@parcel-transformer-raw-npm-2.12.0-bd2cb66ddf-de6681e2e7.zip differ diff --git a/.yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.10.0-4c3ddcc095-fc3163bcb0.zip b/.yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.12.0-59ed68910f-9aba8c1ab0.zip similarity index 61% rename from .yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.10.0-4c3ddcc095-fc3163bcb0.zip rename to .yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.12.0-59ed68910f-9aba8c1ab0.zip index 67e2da6e22..23210becb7 100644 Binary files a/.yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.10.0-4c3ddcc095-fc3163bcb0.zip and b/.yarn/cache/@parcel-transformer-react-refresh-wrap-npm-2.12.0-59ed68910f-9aba8c1ab0.zip differ diff --git a/.yarn/cache/@parcel-transformer-sass-npm-2.10.0-6c5f188bcc-2d697077ac.zip b/.yarn/cache/@parcel-transformer-sass-npm-2.12.0-ef787eef35-ce6b4d329b.zip similarity index 51% rename from .yarn/cache/@parcel-transformer-sass-npm-2.10.0-6c5f188bcc-2d697077ac.zip rename to .yarn/cache/@parcel-transformer-sass-npm-2.12.0-ef787eef35-ce6b4d329b.zip index 3caad5c9a7..d62c342067 100644 Binary files a/.yarn/cache/@parcel-transformer-sass-npm-2.10.0-6c5f188bcc-2d697077ac.zip and b/.yarn/cache/@parcel-transformer-sass-npm-2.12.0-ef787eef35-ce6b4d329b.zip differ diff --git a/.yarn/cache/@parcel-transformer-svg-npm-2.10.0-881c72cd1f-d5f55f6eee.zip b/.yarn/cache/@parcel-transformer-svg-npm-2.12.0-f41b181676-92b7c65894.zip similarity index 71% rename from .yarn/cache/@parcel-transformer-svg-npm-2.10.0-881c72cd1f-d5f55f6eee.zip rename to .yarn/cache/@parcel-transformer-svg-npm-2.12.0-f41b181676-92b7c65894.zip index b6ef15207c..01af21f6a3 100644 Binary files a/.yarn/cache/@parcel-transformer-svg-npm-2.10.0-881c72cd1f-d5f55f6eee.zip and b/.yarn/cache/@parcel-transformer-svg-npm-2.12.0-f41b181676-92b7c65894.zip differ diff --git a/.yarn/cache/@parcel-types-npm-2.10.0-270e786ba1-387aa07902.zip b/.yarn/cache/@parcel-types-npm-2.10.0-270e786ba1-387aa07902.zip deleted file mode 100644 index 59297ef889..0000000000 Binary files a/.yarn/cache/@parcel-types-npm-2.10.0-270e786ba1-387aa07902.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-types-npm-2.12.0-ffe47febbf-250f95580c.zip b/.yarn/cache/@parcel-types-npm-2.12.0-ffe47febbf-250f95580c.zip new file mode 100644 index 0000000000..ea6decc566 Binary files /dev/null and b/.yarn/cache/@parcel-types-npm-2.12.0-ffe47febbf-250f95580c.zip differ diff --git a/.yarn/cache/@parcel-utils-npm-2.10.0-1f25fbc366-9f4953ff9a.zip b/.yarn/cache/@parcel-utils-npm-2.10.0-1f25fbc366-9f4953ff9a.zip deleted file mode 100644 index 5162312f75..0000000000 Binary files a/.yarn/cache/@parcel-utils-npm-2.10.0-1f25fbc366-9f4953ff9a.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-utils-npm-2.12.0-d8a9a48a66-ba80a60fed.zip b/.yarn/cache/@parcel-utils-npm-2.12.0-d8a9a48a66-ba80a60fed.zip new file mode 100644 index 0000000000..8eda598941 Binary files /dev/null and b/.yarn/cache/@parcel-utils-npm-2.12.0-d8a9a48a66-ba80a60fed.zip differ diff --git a/.yarn/cache/@parcel-workers-npm-2.10.0-7f8aa5ad5a-e8b1701b53.zip b/.yarn/cache/@parcel-workers-npm-2.10.0-7f8aa5ad5a-e8b1701b53.zip deleted file mode 100644 index d3e73822e5..0000000000 Binary files a/.yarn/cache/@parcel-workers-npm-2.10.0-7f8aa5ad5a-e8b1701b53.zip and /dev/null differ diff --git a/.yarn/cache/@parcel-workers-npm-2.12.0-3ddd4664bc-e19c3c0a66.zip b/.yarn/cache/@parcel-workers-npm-2.12.0-3ddd4664bc-e19c3c0a66.zip new file mode 100644 index 0000000000..53f28c9470 Binary files /dev/null and b/.yarn/cache/@parcel-workers-npm-2.12.0-3ddd4664bc-e19c3c0a66.zip differ diff --git a/.yarn/cache/@rollup-pluginutils-npm-5.0.5-cfa8fafc53-dcd4d6e3cb.zip b/.yarn/cache/@rollup-pluginutils-npm-5.0.5-cfa8fafc53-dcd4d6e3cb.zip deleted file mode 100644 index 91a5a69139..0000000000 Binary files a/.yarn/cache/@rollup-pluginutils-npm-5.0.5-cfa8fafc53-dcd4d6e3cb.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-pluginutils-npm-5.1.0-6939820ef8-3cc5a6d914.zip b/.yarn/cache/@rollup-pluginutils-npm-5.1.0-6939820ef8-3cc5a6d914.zip new file mode 100644 index 0000000000..923a7a91a8 Binary files /dev/null and b/.yarn/cache/@rollup-pluginutils-npm-5.1.0-6939820ef8-3cc5a6d914.zip differ diff --git a/.yarn/cache/@sidvind-better-ajv-errors-npm-2.0.0-3531bddef9-12b0d87855.zip b/.yarn/cache/@sidvind-better-ajv-errors-npm-2.0.0-3531bddef9-12b0d87855.zip deleted file mode 100644 index 5990cc7604..0000000000 Binary files a/.yarn/cache/@sidvind-better-ajv-errors-npm-2.0.0-3531bddef9-12b0d87855.zip and /dev/null differ diff --git a/.yarn/cache/@sidvind-better-ajv-errors-npm-2.1.3-e3d1c524a8-949cb805a1.zip b/.yarn/cache/@sidvind-better-ajv-errors-npm-2.1.3-e3d1c524a8-949cb805a1.zip new file mode 100644 index 0000000000..ad36770e19 Binary files /dev/null and b/.yarn/cache/@sidvind-better-ajv-errors-npm-2.1.3-e3d1c524a8-949cb805a1.zip differ diff --git a/.yarn/cache/@vitejs-plugin-vue-npm-4.4.0-c33d65c6f6-37b6987951.zip b/.yarn/cache/@vitejs-plugin-vue-npm-4.4.0-c33d65c6f6-37b6987951.zip deleted file mode 100644 index 96bb8b3013..0000000000 Binary files a/.yarn/cache/@vitejs-plugin-vue-npm-4.4.0-c33d65c6f6-37b6987951.zip and /dev/null differ diff --git a/.yarn/cache/@vitejs-plugin-vue-npm-4.6.2-d7ace53203-01bc4ed643.zip b/.yarn/cache/@vitejs-plugin-vue-npm-4.6.2-d7ace53203-01bc4ed643.zip new file mode 100644 index 0000000000..7cf07fbe2d Binary files /dev/null and b/.yarn/cache/@vitejs-plugin-vue-npm-4.6.2-d7ace53203-01bc4ed643.zip differ diff --git a/.yarn/cache/@volar-language-core-npm-2.1.4-18ee1a037d-7430f65143.zip b/.yarn/cache/@volar-language-core-npm-2.1.4-18ee1a037d-7430f65143.zip new file mode 100644 index 0000000000..25e6d3f94d Binary files /dev/null and b/.yarn/cache/@volar-language-core-npm-2.1.4-18ee1a037d-7430f65143.zip differ diff --git a/.yarn/cache/@volar-language-service-npm-2.1.4-2d34cb628f-06cdcfacf0.zip b/.yarn/cache/@volar-language-service-npm-2.1.4-2d34cb628f-06cdcfacf0.zip new file mode 100644 index 0000000000..5f494d902e Binary files /dev/null and b/.yarn/cache/@volar-language-service-npm-2.1.4-2d34cb628f-06cdcfacf0.zip differ diff --git a/.yarn/cache/@volar-source-map-npm-2.1.4-5963b1701f-e2f65bcfd6.zip b/.yarn/cache/@volar-source-map-npm-2.1.4-5963b1701f-e2f65bcfd6.zip new file mode 100644 index 0000000000..0ea96c4d97 Binary files /dev/null and b/.yarn/cache/@volar-source-map-npm-2.1.4-5963b1701f-e2f65bcfd6.zip differ diff --git a/.yarn/cache/@vscode-l10n-npm-0.0.18-8a12efe4b5-c33876cebd.zip b/.yarn/cache/@vscode-l10n-npm-0.0.18-8a12efe4b5-c33876cebd.zip new file mode 100644 index 0000000000..2d6533a204 Binary files /dev/null and b/.yarn/cache/@vscode-l10n-npm-0.0.18-8a12efe4b5-c33876cebd.zip differ diff --git a/.yarn/cache/@vue-compiler-core-npm-3.3.7-584a90831e-94ac56a5a8.zip b/.yarn/cache/@vue-compiler-core-npm-3.3.7-584a90831e-94ac56a5a8.zip deleted file mode 100644 index 7a97cc0f06..0000000000 Binary files a/.yarn/cache/@vue-compiler-core-npm-3.3.7-584a90831e-94ac56a5a8.zip and /dev/null differ diff --git a/.yarn/cache/@vue-compiler-core-npm-3.4.21-ec7f24d7f5-0d6b7732bc.zip b/.yarn/cache/@vue-compiler-core-npm-3.4.21-ec7f24d7f5-0d6b7732bc.zip new file mode 100644 index 0000000000..ba6ec89e54 Binary files /dev/null and b/.yarn/cache/@vue-compiler-core-npm-3.4.21-ec7f24d7f5-0d6b7732bc.zip differ diff --git a/.yarn/cache/@vue-compiler-dom-npm-3.3.7-53a75677b1-d54c49fd82.zip b/.yarn/cache/@vue-compiler-dom-npm-3.3.7-53a75677b1-d54c49fd82.zip deleted file mode 100644 index e575fe111a..0000000000 Binary files a/.yarn/cache/@vue-compiler-dom-npm-3.3.7-53a75677b1-d54c49fd82.zip and /dev/null differ diff --git a/.yarn/cache/@vue-compiler-dom-npm-3.4.21-3d49f99020-f53e4f4e0a.zip b/.yarn/cache/@vue-compiler-dom-npm-3.4.21-3d49f99020-f53e4f4e0a.zip new file mode 100644 index 0000000000..4d0c8cd01f Binary files /dev/null and b/.yarn/cache/@vue-compiler-dom-npm-3.4.21-3d49f99020-f53e4f4e0a.zip differ diff --git a/.yarn/cache/@vue-compiler-sfc-npm-3.3.7-57757e7ccc-593c0b00f3.zip b/.yarn/cache/@vue-compiler-sfc-npm-3.3.7-57757e7ccc-593c0b00f3.zip deleted file mode 100644 index 54122b57ef..0000000000 Binary files a/.yarn/cache/@vue-compiler-sfc-npm-3.3.7-57757e7ccc-593c0b00f3.zip and /dev/null differ diff --git a/.yarn/cache/@vue-compiler-sfc-npm-3.4.21-c2b76ee1ff-226dc404be.zip b/.yarn/cache/@vue-compiler-sfc-npm-3.4.21-c2b76ee1ff-226dc404be.zip new file mode 100644 index 0000000000..95e0d0d70c Binary files /dev/null and b/.yarn/cache/@vue-compiler-sfc-npm-3.4.21-c2b76ee1ff-226dc404be.zip differ diff --git a/.yarn/cache/@vue-compiler-ssr-npm-3.3.7-61efc0860e-42f8ddc9ff.zip b/.yarn/cache/@vue-compiler-ssr-npm-3.3.7-61efc0860e-42f8ddc9ff.zip deleted file mode 100644 index 31da201aef..0000000000 Binary files a/.yarn/cache/@vue-compiler-ssr-npm-3.3.7-61efc0860e-42f8ddc9ff.zip and /dev/null differ diff --git a/.yarn/cache/@vue-compiler-ssr-npm-3.4.21-e6f043341e-c510bee68b.zip b/.yarn/cache/@vue-compiler-ssr-npm-3.4.21-e6f043341e-c510bee68b.zip new file mode 100644 index 0000000000..f03e17b080 Binary files /dev/null and b/.yarn/cache/@vue-compiler-ssr-npm-3.4.21-e6f043341e-c510bee68b.zip differ diff --git a/.yarn/cache/@vue-devtools-api-npm-6.6.1-ef3c82703e-cf12b5ebcc.zip b/.yarn/cache/@vue-devtools-api-npm-6.6.1-ef3c82703e-cf12b5ebcc.zip new file mode 100644 index 0000000000..f14e2cdac7 Binary files /dev/null and b/.yarn/cache/@vue-devtools-api-npm-6.6.1-ef3c82703e-cf12b5ebcc.zip differ diff --git a/.yarn/cache/@vue-language-plugin-pug-npm-2.0.7-547300c7e0-11cc96eb5f.zip b/.yarn/cache/@vue-language-plugin-pug-npm-2.0.7-547300c7e0-11cc96eb5f.zip new file mode 100644 index 0000000000..e637e5f556 Binary files /dev/null and b/.yarn/cache/@vue-language-plugin-pug-npm-2.0.7-547300c7e0-11cc96eb5f.zip differ diff --git a/.yarn/cache/@vue-reactivity-npm-3.3.7-0bf3bc7094-16b629c997.zip b/.yarn/cache/@vue-reactivity-npm-3.3.7-0bf3bc7094-16b629c997.zip deleted file mode 100644 index 38b5fa6d04..0000000000 Binary files a/.yarn/cache/@vue-reactivity-npm-3.3.7-0bf3bc7094-16b629c997.zip and /dev/null differ diff --git a/.yarn/cache/@vue-reactivity-npm-3.4.21-fd3e254d08-79c7ebe3ec.zip b/.yarn/cache/@vue-reactivity-npm-3.4.21-fd3e254d08-79c7ebe3ec.zip new file mode 100644 index 0000000000..adc965a473 Binary files /dev/null and b/.yarn/cache/@vue-reactivity-npm-3.4.21-fd3e254d08-79c7ebe3ec.zip differ diff --git a/.yarn/cache/@vue-reactivity-transform-npm-3.3.7-7c404f0363-f88d39c8a4.zip b/.yarn/cache/@vue-reactivity-transform-npm-3.3.7-7c404f0363-f88d39c8a4.zip deleted file mode 100644 index 4575a9337f..0000000000 Binary files a/.yarn/cache/@vue-reactivity-transform-npm-3.3.7-7c404f0363-f88d39c8a4.zip and /dev/null differ diff --git a/.yarn/cache/@vue-runtime-core-npm-3.3.7-61d8aab53a-3fd38c6831.zip b/.yarn/cache/@vue-runtime-core-npm-3.3.7-61d8aab53a-3fd38c6831.zip deleted file mode 100644 index 368c70512c..0000000000 Binary files a/.yarn/cache/@vue-runtime-core-npm-3.3.7-61d8aab53a-3fd38c6831.zip and /dev/null differ diff --git a/.yarn/cache/@vue-runtime-core-npm-3.4.21-7bf985040b-4eb9b5d91f.zip b/.yarn/cache/@vue-runtime-core-npm-3.4.21-7bf985040b-4eb9b5d91f.zip new file mode 100644 index 0000000000..ffb48a907a Binary files /dev/null and b/.yarn/cache/@vue-runtime-core-npm-3.4.21-7bf985040b-4eb9b5d91f.zip differ diff --git a/.yarn/cache/@vue-runtime-dom-npm-3.3.7-07cf6cc840-9331ea0886.zip b/.yarn/cache/@vue-runtime-dom-npm-3.3.7-07cf6cc840-9331ea0886.zip deleted file mode 100644 index bd517fed0a..0000000000 Binary files a/.yarn/cache/@vue-runtime-dom-npm-3.3.7-07cf6cc840-9331ea0886.zip and /dev/null differ diff --git a/.yarn/cache/@vue-runtime-dom-npm-3.4.21-40f99cf9a2-ebfdaa081f.zip b/.yarn/cache/@vue-runtime-dom-npm-3.4.21-40f99cf9a2-ebfdaa081f.zip new file mode 100644 index 0000000000..c65601f0a9 Binary files /dev/null and b/.yarn/cache/@vue-runtime-dom-npm-3.4.21-40f99cf9a2-ebfdaa081f.zip differ diff --git a/.yarn/cache/@vue-server-renderer-npm-3.3.7-a66f8ec338-c2e673d6f7.zip b/.yarn/cache/@vue-server-renderer-npm-3.3.7-a66f8ec338-c2e673d6f7.zip deleted file mode 100644 index c037fccafd..0000000000 Binary files a/.yarn/cache/@vue-server-renderer-npm-3.3.7-a66f8ec338-c2e673d6f7.zip and /dev/null differ diff --git a/.yarn/cache/@vue-server-renderer-npm-3.4.21-bf6b2daebb-faa3dc4876.zip b/.yarn/cache/@vue-server-renderer-npm-3.4.21-bf6b2daebb-faa3dc4876.zip new file mode 100644 index 0000000000..4da755254b Binary files /dev/null and b/.yarn/cache/@vue-server-renderer-npm-3.4.21-bf6b2daebb-faa3dc4876.zip differ diff --git a/.yarn/cache/@vue-shared-npm-3.3.7-44832a6399-a6718f760b.zip b/.yarn/cache/@vue-shared-npm-3.3.7-44832a6399-a6718f760b.zip deleted file mode 100644 index c65bb7407a..0000000000 Binary files a/.yarn/cache/@vue-shared-npm-3.3.7-44832a6399-a6718f760b.zip and /dev/null differ diff --git a/.yarn/cache/@vue-shared-npm-3.4.21-2aee4ae0bc-5f30a40891.zip b/.yarn/cache/@vue-shared-npm-3.4.21-2aee4ae0bc-5f30a40891.zip new file mode 100644 index 0000000000..01c52809b3 Binary files /dev/null and b/.yarn/cache/@vue-shared-npm-3.4.21-2aee4ae0bc-5f30a40891.zip differ diff --git a/.yarn/cache/bootstrap-icons-npm-1.11.1-9f55aea76a-d78ff24a83.zip b/.yarn/cache/bootstrap-icons-npm-1.11.1-9f55aea76a-d78ff24a83.zip deleted file mode 100644 index 8124ba6569..0000000000 Binary files a/.yarn/cache/bootstrap-icons-npm-1.11.1-9f55aea76a-d78ff24a83.zip and /dev/null differ diff --git a/.yarn/cache/bootstrap-icons-npm-1.11.3-8d5387bef2-d5cdb90fe3.zip b/.yarn/cache/bootstrap-icons-npm-1.11.3-8d5387bef2-d5cdb90fe3.zip new file mode 100644 index 0000000000..e20ab2ecb3 Binary files /dev/null and b/.yarn/cache/bootstrap-icons-npm-1.11.3-8d5387bef2-d5cdb90fe3.zip differ diff --git a/.yarn/cache/bootstrap-npm-5.3.2-20b391b636-d5580b253d.zip b/.yarn/cache/bootstrap-npm-5.3.2-20b391b636-d5580b253d.zip deleted file mode 100644 index a575a52461..0000000000 Binary files a/.yarn/cache/bootstrap-npm-5.3.2-20b391b636-d5580b253d.zip and /dev/null differ diff --git a/.yarn/cache/bootstrap-npm-5.3.3-da08e2f0fe-537b68db30.zip b/.yarn/cache/bootstrap-npm-5.3.3-da08e2f0fe-537b68db30.zip new file mode 100644 index 0000000000..ca3961acc1 Binary files /dev/null and b/.yarn/cache/bootstrap-npm-5.3.3-da08e2f0fe-537b68db30.zip differ diff --git a/.yarn/cache/builtin-modules-npm-3.3.0-db4f3d32de-db021755d7.zip b/.yarn/cache/builtin-modules-npm-3.3.0-db4f3d32de-db021755d7.zip new file mode 100644 index 0000000000..c7e20444c6 Binary files /dev/null and b/.yarn/cache/builtin-modules-npm-3.3.0-db4f3d32de-db021755d7.zip differ diff --git a/.yarn/cache/c8-npm-8.0.1-6462c8130b-2c47531d21.zip b/.yarn/cache/c8-npm-9.1.0-92c3d37f46-c5249bf9c3.zip similarity index 50% rename from .yarn/cache/c8-npm-8.0.1-6462c8130b-2c47531d21.zip rename to .yarn/cache/c8-npm-9.1.0-92c3d37f46-c5249bf9c3.zip index ff8bf91a24..1e5812b784 100644 Binary files a/.yarn/cache/c8-npm-8.0.1-6462c8130b-2c47531d21.zip and b/.yarn/cache/c8-npm-9.1.0-92c3d37f46-c5249bf9c3.zip differ diff --git a/.yarn/cache/caniuse-lite-npm-1.0.30001538-68bfe8259b-94c5d55757.zip b/.yarn/cache/caniuse-lite-npm-1.0.30001538-68bfe8259b-94c5d55757.zip deleted file mode 100644 index 12c494fb8d..0000000000 Binary files a/.yarn/cache/caniuse-lite-npm-1.0.30001538-68bfe8259b-94c5d55757.zip and /dev/null differ diff --git a/.yarn/cache/caniuse-lite-npm-1.0.30001599-834cd4cb82-d7e619e2e7.zip b/.yarn/cache/caniuse-lite-npm-1.0.30001599-834cd4cb82-d7e619e2e7.zip new file mode 100644 index 0000000000..a87ecfaa8e Binary files /dev/null and b/.yarn/cache/caniuse-lite-npm-1.0.30001599-834cd4cb82-d7e619e2e7.zip differ diff --git a/.yarn/cache/csstype-npm-3.1.2-cead7d99b2-e1a52e6c25.zip b/.yarn/cache/csstype-npm-3.1.2-cead7d99b2-e1a52e6c25.zip deleted file mode 100644 index 740042eb39..0000000000 Binary files a/.yarn/cache/csstype-npm-3.1.2-cead7d99b2-e1a52e6c25.zip and /dev/null differ diff --git a/.yarn/cache/csstype-npm-3.1.3-e9a1c85013-8db785cc92.zip b/.yarn/cache/csstype-npm-3.1.3-e9a1c85013-8db785cc92.zip new file mode 100644 index 0000000000..9853f0cf0b Binary files /dev/null and b/.yarn/cache/csstype-npm-3.1.3-e9a1c85013-8db785cc92.zip differ diff --git a/.yarn/cache/d3-npm-7.8.5-5db20a5616-e407e79731.zip b/.yarn/cache/d3-npm-7.8.5-5db20a5616-e407e79731.zip deleted file mode 100644 index b06dd3f260..0000000000 Binary files a/.yarn/cache/d3-npm-7.8.5-5db20a5616-e407e79731.zip and /dev/null differ diff --git a/.yarn/cache/d3-npm-7.9.0-d293821ce6-1c0e9135f1.zip b/.yarn/cache/d3-npm-7.9.0-d293821ce6-1c0e9135f1.zip new file mode 100644 index 0000000000..e78ffffee5 Binary files /dev/null and b/.yarn/cache/d3-npm-7.9.0-d293821ce6-1c0e9135f1.zip differ diff --git a/.yarn/cache/entities-npm-4.5.0-7cdb83b832-853f8ebd5b.zip b/.yarn/cache/entities-npm-4.5.0-7cdb83b832-853f8ebd5b.zip new file mode 100644 index 0000000000..3772a4510c Binary files /dev/null and b/.yarn/cache/entities-npm-4.5.0-7cdb83b832-853f8ebd5b.zip differ diff --git a/.yarn/cache/eslint-compat-utils-npm-0.1.2-361c6992b1-2315d9db81.zip b/.yarn/cache/eslint-compat-utils-npm-0.1.2-361c6992b1-2315d9db81.zip new file mode 100644 index 0000000000..505e336b08 Binary files /dev/null and b/.yarn/cache/eslint-compat-utils-npm-0.1.2-361c6992b1-2315d9db81.zip differ diff --git a/.yarn/cache/eslint-npm-8.52.0-e7f048a439-fd22d1e9bd.zip b/.yarn/cache/eslint-npm-8.57.0-4286e12a3a-3a48d7ff85.zip similarity index 61% rename from .yarn/cache/eslint-npm-8.52.0-e7f048a439-fd22d1e9bd.zip rename to .yarn/cache/eslint-npm-8.57.0-4286e12a3a-3a48d7ff85.zip index 625d3a60da..73f8f9dff6 100644 Binary files a/.yarn/cache/eslint-npm-8.52.0-e7f048a439-fd22d1e9bd.zip and b/.yarn/cache/eslint-npm-8.57.0-4286e12a3a-3a48d7ff85.zip differ diff --git a/.yarn/cache/eslint-plugin-es-x-npm-7.1.0-35735e8bbc-a19924313c.zip b/.yarn/cache/eslint-plugin-es-x-npm-7.1.0-35735e8bbc-a19924313c.zip deleted file mode 100644 index 4184ed69d2..0000000000 Binary files a/.yarn/cache/eslint-plugin-es-x-npm-7.1.0-35735e8bbc-a19924313c.zip and /dev/null differ diff --git a/.yarn/cache/eslint-plugin-es-x-npm-7.5.0-77e84d6e5d-e770e57df7.zip b/.yarn/cache/eslint-plugin-es-x-npm-7.5.0-77e84d6e5d-e770e57df7.zip new file mode 100644 index 0000000000..1d334e0a10 Binary files /dev/null and b/.yarn/cache/eslint-plugin-es-x-npm-7.5.0-77e84d6e5d-e770e57df7.zip differ diff --git a/.yarn/cache/eslint-plugin-import-npm-2.29.0-9cd6da0b0a-19ee541fb9.zip b/.yarn/cache/eslint-plugin-import-npm-2.29.1-b94305f7dc-e65159aef8.zip similarity index 83% rename from .yarn/cache/eslint-plugin-import-npm-2.29.0-9cd6da0b0a-19ee541fb9.zip rename to .yarn/cache/eslint-plugin-import-npm-2.29.1-b94305f7dc-e65159aef8.zip index ed15139635..bc424a6a64 100644 Binary files a/.yarn/cache/eslint-plugin-import-npm-2.29.0-9cd6da0b0a-19ee541fb9.zip and b/.yarn/cache/eslint-plugin-import-npm-2.29.1-b94305f7dc-e65159aef8.zip differ diff --git a/.yarn/cache/eslint-plugin-n-npm-16.2.0-b2b8355312-124ba4f418.zip b/.yarn/cache/eslint-plugin-n-npm-16.2.0-b2b8355312-124ba4f418.zip deleted file mode 100644 index 12357638c9..0000000000 Binary files a/.yarn/cache/eslint-plugin-n-npm-16.2.0-b2b8355312-124ba4f418.zip and /dev/null differ diff --git a/.yarn/cache/eslint-plugin-n-npm-16.6.2-77775852d0-3b468da003.zip b/.yarn/cache/eslint-plugin-n-npm-16.6.2-77775852d0-3b468da003.zip new file mode 100644 index 0000000000..9c7224993f Binary files /dev/null and b/.yarn/cache/eslint-plugin-n-npm-16.6.2-77775852d0-3b468da003.zip differ diff --git a/.yarn/cache/eslint-plugin-vue-npm-9.18.1-69de725f8c-774c1e9163.zip b/.yarn/cache/eslint-plugin-vue-npm-9.18.1-69de725f8c-774c1e9163.zip deleted file mode 100644 index d846217f1c..0000000000 Binary files a/.yarn/cache/eslint-plugin-vue-npm-9.18.1-69de725f8c-774c1e9163.zip and /dev/null differ diff --git a/.yarn/cache/eslint-plugin-vue-npm-9.23.0-d36e581933-acb3a4dd27.zip b/.yarn/cache/eslint-plugin-vue-npm-9.23.0-d36e581933-acb3a4dd27.zip new file mode 100644 index 0000000000..738f5c44c5 Binary files /dev/null and b/.yarn/cache/eslint-plugin-vue-npm-9.23.0-d36e581933-acb3a4dd27.zip differ diff --git a/.yarn/cache/foreground-child-npm-2.0.0-80c976b61e-f77ec9aff6.zip b/.yarn/cache/foreground-child-npm-2.0.0-80c976b61e-f77ec9aff6.zip deleted file mode 100644 index d947311d1e..0000000000 Binary files a/.yarn/cache/foreground-child-npm-2.0.0-80c976b61e-f77ec9aff6.zip and /dev/null differ diff --git a/.yarn/cache/globals-npm-13.24.0-cc7713139c-56066ef058.zip b/.yarn/cache/globals-npm-13.24.0-cc7713139c-56066ef058.zip new file mode 100644 index 0000000000..c8cb0244af Binary files /dev/null and b/.yarn/cache/globals-npm-13.24.0-cc7713139c-56066ef058.zip differ diff --git a/.yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip b/.yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip deleted file mode 100644 index ccf9aece97..0000000000 Binary files a/.yarn/cache/highcharts-npm-11.1.0-0d42a04430-f9b8cdc38b.zip and /dev/null differ diff --git a/.yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip b/.yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip new file mode 100644 index 0000000000..9c2f2df154 Binary files /dev/null and b/.yarn/cache/highcharts-npm-11.4.0-8a1f46b545-873e661914.zip differ diff --git a/.yarn/cache/html-validate-npm-8.16.0-71c478330f-857b05ab87.zip b/.yarn/cache/html-validate-npm-8.16.0-71c478330f-857b05ab87.zip new file mode 100644 index 0000000000..3ae560c224 Binary files /dev/null and b/.yarn/cache/html-validate-npm-8.16.0-71c478330f-857b05ab87.zip differ diff --git a/.yarn/cache/html-validate-npm-8.7.0-7989d0b76e-f1b346bad8.zip b/.yarn/cache/html-validate-npm-8.7.0-7989d0b76e-f1b346bad8.zip deleted file mode 100644 index 632fbe666f..0000000000 Binary files a/.yarn/cache/html-validate-npm-8.7.0-7989d0b76e-f1b346bad8.zip and /dev/null differ diff --git a/.yarn/cache/ignore-npm-5.3.1-f6947c5df7-71d7bb4c1d.zip b/.yarn/cache/ignore-npm-5.3.1-f6947c5df7-71d7bb4c1d.zip new file mode 100644 index 0000000000..75ba53a270 Binary files /dev/null and b/.yarn/cache/ignore-npm-5.3.1-f6947c5df7-71d7bb4c1d.zip differ diff --git a/.yarn/cache/is-builtin-module-npm-3.2.1-2f92a5d353-e8f0ffc19a.zip b/.yarn/cache/is-builtin-module-npm-3.2.1-2f92a5d353-e8f0ffc19a.zip new file mode 100644 index 0000000000..be908976b5 Binary files /dev/null and b/.yarn/cache/is-builtin-module-npm-3.2.1-2f92a5d353-e8f0ffc19a.zip differ diff --git a/.yarn/cache/luxon-npm-3.4.3-1b54517fa6-3eade81506.zip b/.yarn/cache/luxon-npm-3.4.3-1b54517fa6-3eade81506.zip deleted file mode 100644 index 3089c36a81..0000000000 Binary files a/.yarn/cache/luxon-npm-3.4.3-1b54517fa6-3eade81506.zip and /dev/null differ diff --git a/.yarn/cache/luxon-npm-3.4.4-c93f95dde8-36c1f99c47.zip b/.yarn/cache/luxon-npm-3.4.4-c93f95dde8-36c1f99c47.zip new file mode 100644 index 0000000000..ed7709ee9e Binary files /dev/null and b/.yarn/cache/luxon-npm-3.4.4-c93f95dde8-36c1f99c47.zip differ diff --git a/.yarn/cache/magic-string-npm-0.30.5-dffb7e6a73-da10fecff0.zip b/.yarn/cache/magic-string-npm-0.30.5-dffb7e6a73-da10fecff0.zip deleted file mode 100644 index 649679c9b0..0000000000 Binary files a/.yarn/cache/magic-string-npm-0.30.5-dffb7e6a73-da10fecff0.zip and /dev/null differ diff --git a/.yarn/cache/magic-string-npm-0.30.7-0bb5819095-bdf102e36a.zip b/.yarn/cache/magic-string-npm-0.30.7-0bb5819095-bdf102e36a.zip new file mode 100644 index 0000000000..7d9e6ff1d3 Binary files /dev/null and b/.yarn/cache/magic-string-npm-0.30.7-0bb5819095-bdf102e36a.zip differ diff --git a/.yarn/cache/moment-npm-2.30.1-1c51a5c631-859236bab1.zip b/.yarn/cache/moment-npm-2.30.1-1c51a5c631-859236bab1.zip new file mode 100644 index 0000000000..7454cc21af Binary files /dev/null and b/.yarn/cache/moment-npm-2.30.1-1c51a5c631-859236bab1.zip differ diff --git a/.yarn/cache/moment-timezone-npm-0.5.43-1304d8602a-8075c897ed.zip b/.yarn/cache/moment-timezone-npm-0.5.43-1304d8602a-8075c897ed.zip deleted file mode 100644 index 6200ccaec8..0000000000 Binary files a/.yarn/cache/moment-timezone-npm-0.5.43-1304d8602a-8075c897ed.zip and /dev/null differ diff --git a/.yarn/cache/moment-timezone-npm-0.5.45-2df3ad72a4-a22e9f983f.zip b/.yarn/cache/moment-timezone-npm-0.5.45-2df3ad72a4-a22e9f983f.zip new file mode 100644 index 0000000000..4cd7864ca5 Binary files /dev/null and b/.yarn/cache/moment-timezone-npm-0.5.45-2df3ad72a4-a22e9f983f.zip differ diff --git a/.yarn/cache/msgpackr-npm-1.10.1-5c5ff5c553-e422d18b01.zip b/.yarn/cache/msgpackr-npm-1.10.1-5c5ff5c553-e422d18b01.zip new file mode 100644 index 0000000000..12aaa36344 Binary files /dev/null and b/.yarn/cache/msgpackr-npm-1.10.1-5c5ff5c553-e422d18b01.zip differ diff --git a/.yarn/cache/muggle-string-npm-0.4.1-fe3c825cc2-85fe1766d1.zip b/.yarn/cache/muggle-string-npm-0.4.1-fe3c825cc2-85fe1766d1.zip new file mode 100644 index 0000000000..4cec1b177d Binary files /dev/null and b/.yarn/cache/muggle-string-npm-0.4.1-fe3c825cc2-85fe1766d1.zip differ diff --git a/.yarn/cache/naive-ui-npm-2.35.0-2bb3f5a46d-53239b8cbe.zip b/.yarn/cache/naive-ui-npm-2.35.0-2bb3f5a46d-53239b8cbe.zip deleted file mode 100644 index 79503fb7af..0000000000 Binary files a/.yarn/cache/naive-ui-npm-2.35.0-2bb3f5a46d-53239b8cbe.zip and /dev/null differ diff --git a/.yarn/cache/naive-ui-npm-2.38.1-0edd2e5816-88a8f981de.zip b/.yarn/cache/naive-ui-npm-2.38.1-0edd2e5816-88a8f981de.zip new file mode 100644 index 0000000000..fb6dc789a1 Binary files /dev/null and b/.yarn/cache/naive-ui-npm-2.38.1-0edd2e5816-88a8f981de.zip differ diff --git a/.yarn/cache/nanoid-npm-3.3.6-e6d6ae7e71-7d0eda6570.zip b/.yarn/cache/nanoid-npm-3.3.7-98824ba130-d36c427e53.zip similarity index 70% rename from .yarn/cache/nanoid-npm-3.3.6-e6d6ae7e71-7d0eda6570.zip rename to .yarn/cache/nanoid-npm-3.3.7-98824ba130-d36c427e53.zip index 8526acad72..7b2fd6e1b5 100644 Binary files a/.yarn/cache/nanoid-npm-3.3.6-e6d6ae7e71-7d0eda6570.zip and b/.yarn/cache/nanoid-npm-3.3.7-98824ba130-d36c427e53.zip differ diff --git a/.yarn/cache/parcel-npm-2.10.0-8e794fc289-fe25ddcf2d.zip b/.yarn/cache/parcel-npm-2.10.0-8e794fc289-fe25ddcf2d.zip deleted file mode 100644 index 018a98c3b8..0000000000 Binary files a/.yarn/cache/parcel-npm-2.10.0-8e794fc289-fe25ddcf2d.zip and /dev/null differ diff --git a/.yarn/cache/parcel-npm-2.12.0-96a4bb6cc3-d8e6cb690a.zip b/.yarn/cache/parcel-npm-2.12.0-96a4bb6cc3-d8e6cb690a.zip new file mode 100644 index 0000000000..965ad65ddc Binary files /dev/null and b/.yarn/cache/parcel-npm-2.12.0-96a4bb6cc3-d8e6cb690a.zip differ diff --git a/.yarn/cache/postcss-npm-8.4.27-2a9f5f8f40-1cdd0c2988.zip b/.yarn/cache/postcss-npm-8.4.27-2a9f5f8f40-1cdd0c2988.zip deleted file mode 100644 index 11d492b6c1..0000000000 Binary files a/.yarn/cache/postcss-npm-8.4.27-2a9f5f8f40-1cdd0c2988.zip and /dev/null differ diff --git a/.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip b/.yarn/cache/postcss-npm-8.4.33-6ba8157009-6f98b2af4b.zip similarity index 84% rename from .yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip rename to .yarn/cache/postcss-npm-8.4.33-6ba8157009-6f98b2af4b.zip index dc7dd6671c..57638cbd81 100644 Binary files a/.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip and b/.yarn/cache/postcss-npm-8.4.33-6ba8157009-6f98b2af4b.zip differ diff --git a/.yarn/cache/postcss-npm-8.4.35-6bc1848fff-cf3c3124d3.zip b/.yarn/cache/postcss-npm-8.4.35-6bc1848fff-cf3c3124d3.zip new file mode 100644 index 0000000000..888dccea0c Binary files /dev/null and b/.yarn/cache/postcss-npm-8.4.35-6bc1848fff-cf3c3124d3.zip differ diff --git a/.yarn/cache/postcss-selector-parser-npm-6.0.13-f732d92326-f89163338a.zip b/.yarn/cache/postcss-selector-parser-npm-6.0.15-0ec4819b4e-57decb9415.zip similarity index 74% rename from .yarn/cache/postcss-selector-parser-npm-6.0.13-f732d92326-f89163338a.zip rename to .yarn/cache/postcss-selector-parser-npm-6.0.15-0ec4819b4e-57decb9415.zip index 1623d46ce1..c6d454663e 100644 Binary files a/.yarn/cache/postcss-selector-parser-npm-6.0.13-f732d92326-f89163338a.zip and b/.yarn/cache/postcss-selector-parser-npm-6.0.15-0ec4819b4e-57decb9415.zip differ diff --git a/.yarn/cache/rollup-npm-3.28.0-4ab1b4022e-6ded4a0d3c.zip b/.yarn/cache/rollup-npm-3.28.0-4ab1b4022e-6ded4a0d3c.zip deleted file mode 100644 index fba58d7eec..0000000000 Binary files a/.yarn/cache/rollup-npm-3.28.0-4ab1b4022e-6ded4a0d3c.zip and /dev/null differ diff --git a/.yarn/cache/rollup-npm-3.29.4-5e5e5f2087-8bb20a39c8.zip b/.yarn/cache/rollup-npm-3.29.4-5e5e5f2087-8bb20a39c8.zip new file mode 100644 index 0000000000..9f6628aa42 Binary files /dev/null and b/.yarn/cache/rollup-npm-3.29.4-5e5e5f2087-8bb20a39c8.zip differ diff --git a/.yarn/cache/sass-npm-1.69.4-bea57e4b30-ed5558445b.zip b/.yarn/cache/sass-npm-1.69.4-bea57e4b30-ed5558445b.zip deleted file mode 100644 index e9db6b0c8c..0000000000 Binary files a/.yarn/cache/sass-npm-1.69.4-bea57e4b30-ed5558445b.zip and /dev/null differ diff --git a/.yarn/cache/sass-npm-1.72.0-fb38bb530c-f420079c7d.zip b/.yarn/cache/sass-npm-1.72.0-fb38bb530c-f420079c7d.zip new file mode 100644 index 0000000000..a3aea4e668 Binary files /dev/null and b/.yarn/cache/sass-npm-1.72.0-fb38bb530c-f420079c7d.zip differ diff --git a/.yarn/cache/seemly-npm-0.3.8-4940336497-98171fd4d9.zip b/.yarn/cache/seemly-npm-0.3.8-4940336497-98171fd4d9.zip new file mode 100644 index 0000000000..03ae0a8f50 Binary files /dev/null and b/.yarn/cache/seemly-npm-0.3.8-4940336497-98171fd4d9.zip differ diff --git a/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip b/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip new file mode 100644 index 0000000000..a5494e10ac Binary files /dev/null and b/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip differ diff --git a/.yarn/cache/sortablejs-npm-1.15.0-f3a393abcc-bb82223a66.zip b/.yarn/cache/sortablejs-npm-1.15.0-f3a393abcc-bb82223a66.zip deleted file mode 100644 index 9028b71d1c..0000000000 Binary files a/.yarn/cache/sortablejs-npm-1.15.0-f3a393abcc-bb82223a66.zip and /dev/null differ diff --git a/.yarn/cache/sortablejs-npm-1.15.2-73347ae85a-36b20b144f.zip b/.yarn/cache/sortablejs-npm-1.15.2-73347ae85a-36b20b144f.zip new file mode 100644 index 0000000000..b303125761 Binary files /dev/null and b/.yarn/cache/sortablejs-npm-1.15.2-73347ae85a-36b20b144f.zip differ diff --git a/.yarn/cache/tsconfig-paths-npm-3.14.2-90ce75420d-a6162eaa1a.zip b/.yarn/cache/tsconfig-paths-npm-3.15.0-ff68930e0e-59f35407a3.zip similarity index 69% rename from .yarn/cache/tsconfig-paths-npm-3.14.2-90ce75420d-a6162eaa1a.zip rename to .yarn/cache/tsconfig-paths-npm-3.15.0-ff68930e0e-59f35407a3.zip index 0b76788258..abfe8dd47e 100644 Binary files a/.yarn/cache/tsconfig-paths-npm-3.14.2-90ce75420d-a6162eaa1a.zip and b/.yarn/cache/tsconfig-paths-npm-3.15.0-ff68930e0e-59f35407a3.zip differ diff --git a/.yarn/cache/vite-npm-4.5.0-6fb40946d7-06f1a4c858.zip b/.yarn/cache/vite-npm-4.5.2-e430b2c117-9d1f84f703.zip similarity index 61% rename from .yarn/cache/vite-npm-4.5.0-6fb40946d7-06f1a4c858.zip rename to .yarn/cache/vite-npm-4.5.2-e430b2c117-9d1f84f703.zip index 3c0f689f94..3ba408d602 100644 Binary files a/.yarn/cache/vite-npm-4.5.0-6fb40946d7-06f1a4c858.zip and b/.yarn/cache/vite-npm-4.5.2-e430b2c117-9d1f84f703.zip differ diff --git a/.yarn/cache/volar-service-html-npm-0.0.34-32b6d24136-83b50cd805.zip b/.yarn/cache/volar-service-html-npm-0.0.34-32b6d24136-83b50cd805.zip new file mode 100644 index 0000000000..0f1e9805f7 Binary files /dev/null and b/.yarn/cache/volar-service-html-npm-0.0.34-32b6d24136-83b50cd805.zip differ diff --git a/.yarn/cache/volar-service-pug-npm-0.0.34-6f5429e17c-4691aa1c8e.zip b/.yarn/cache/volar-service-pug-npm-0.0.34-6f5429e17c-4691aa1c8e.zip new file mode 100644 index 0000000000..d53f3521ee Binary files /dev/null and b/.yarn/cache/volar-service-pug-npm-0.0.34-6f5429e17c-4691aa1c8e.zip differ diff --git a/.yarn/cache/vscode-html-languageservice-npm-5.1.2-2ea2618bdd-3a2a5ee5ad.zip b/.yarn/cache/vscode-html-languageservice-npm-5.1.2-2ea2618bdd-3a2a5ee5ad.zip new file mode 100644 index 0000000000..d83607888b Binary files /dev/null and b/.yarn/cache/vscode-html-languageservice-npm-5.1.2-2ea2618bdd-3a2a5ee5ad.zip differ diff --git a/.yarn/cache/vscode-jsonrpc-npm-8.2.0-b7d2e5b553-f302a01e59.zip b/.yarn/cache/vscode-jsonrpc-npm-8.2.0-b7d2e5b553-f302a01e59.zip new file mode 100644 index 0000000000..75e2c086b6 Binary files /dev/null and b/.yarn/cache/vscode-jsonrpc-npm-8.2.0-b7d2e5b553-f302a01e59.zip differ diff --git a/.yarn/cache/vscode-languageserver-protocol-npm-3.17.5-2b07e16989-dfb42d276d.zip b/.yarn/cache/vscode-languageserver-protocol-npm-3.17.5-2b07e16989-dfb42d276d.zip new file mode 100644 index 0000000000..bcb5ae5b4e Binary files /dev/null and b/.yarn/cache/vscode-languageserver-protocol-npm-3.17.5-2b07e16989-dfb42d276d.zip differ diff --git a/.yarn/cache/vscode-languageserver-textdocument-npm-1.0.11-6fc94d2b7b-ea7cdc9d4f.zip b/.yarn/cache/vscode-languageserver-textdocument-npm-1.0.11-6fc94d2b7b-ea7cdc9d4f.zip new file mode 100644 index 0000000000..b1edfda12d Binary files /dev/null and b/.yarn/cache/vscode-languageserver-textdocument-npm-1.0.11-6fc94d2b7b-ea7cdc9d4f.zip differ diff --git a/.yarn/cache/vscode-languageserver-types-npm-3.17.5-aca3b71a5a-79b420e757.zip b/.yarn/cache/vscode-languageserver-types-npm-3.17.5-aca3b71a5a-79b420e757.zip new file mode 100644 index 0000000000..ec214b2903 Binary files /dev/null and b/.yarn/cache/vscode-languageserver-types-npm-3.17.5-aca3b71a5a-79b420e757.zip differ diff --git a/.yarn/cache/vscode-uri-npm-3.0.8-56f46b9d24-5142491268.zip b/.yarn/cache/vscode-uri-npm-3.0.8-56f46b9d24-5142491268.zip new file mode 100644 index 0000000000..6dadd110c9 Binary files /dev/null and b/.yarn/cache/vscode-uri-npm-3.0.8-56f46b9d24-5142491268.zip differ diff --git a/.yarn/cache/vue-eslint-parser-npm-9.3.1-a0feb51670-6d1476b45f.zip b/.yarn/cache/vue-eslint-parser-npm-9.3.1-a0feb51670-6d1476b45f.zip deleted file mode 100644 index 82eadd1443..0000000000 Binary files a/.yarn/cache/vue-eslint-parser-npm-9.3.1-a0feb51670-6d1476b45f.zip and /dev/null differ diff --git a/.yarn/cache/vue-eslint-parser-npm-9.4.2-3e4e696025-67f14c8ea1.zip b/.yarn/cache/vue-eslint-parser-npm-9.4.2-3e4e696025-67f14c8ea1.zip new file mode 100644 index 0000000000..9ec85e189e Binary files /dev/null and b/.yarn/cache/vue-eslint-parser-npm-9.4.2-3e4e696025-67f14c8ea1.zip differ diff --git a/.yarn/cache/vue-npm-3.3.7-6f764474d4-463599d2a5.zip b/.yarn/cache/vue-npm-3.3.7-6f764474d4-463599d2a5.zip deleted file mode 100644 index c1406a0878..0000000000 Binary files a/.yarn/cache/vue-npm-3.3.7-6f764474d4-463599d2a5.zip and /dev/null differ diff --git a/.yarn/cache/vue-npm-3.4.21-02110aa6d9-3c477982a0.zip b/.yarn/cache/vue-npm-3.4.21-02110aa6d9-3c477982a0.zip new file mode 100644 index 0000000000..c48b4e5dfe Binary files /dev/null and b/.yarn/cache/vue-npm-3.4.21-02110aa6d9-3c477982a0.zip differ diff --git a/.yarn/cache/vue-router-npm-4.2.5-3479f41e41-2449db4f3a.zip b/.yarn/cache/vue-router-npm-4.2.5-3479f41e41-2449db4f3a.zip deleted file mode 100644 index 930757dd0b..0000000000 Binary files a/.yarn/cache/vue-router-npm-4.2.5-3479f41e41-2449db4f3a.zip and /dev/null differ diff --git a/.yarn/cache/vue-router-npm-4.3.0-b765d40138-0059261d39.zip b/.yarn/cache/vue-router-npm-4.3.0-b765d40138-0059261d39.zip new file mode 100644 index 0000000000..6b93953624 Binary files /dev/null and b/.yarn/cache/vue-router-npm-4.3.0-b765d40138-0059261d39.zip differ diff --git a/.yarn/cache/vueuc-npm-0.4.51-794074113f-7969659fac.zip b/.yarn/cache/vueuc-npm-0.4.58-be5584770c-fb0b9a69be.zip similarity index 66% rename from .yarn/cache/vueuc-npm-0.4.51-794074113f-7969659fac.zip rename to .yarn/cache/vueuc-npm-0.4.58-be5584770c-fb0b9a69be.zip index 4a273f8340..f62e5e32e8 100644 Binary files a/.yarn/cache/vueuc-npm-0.4.51-794074113f-7969659fac.zip and b/.yarn/cache/vueuc-npm-0.4.58-be5584770c-fb0b9a69be.zip differ diff --git a/bin/daily b/bin/daily index c65ab56043..8211e1e237 100755 --- a/bin/daily +++ b/bin/daily @@ -34,7 +34,8 @@ $DTDIR/ietf/manage.py update_external_command_info rsync -avzq --delete /a/www/ietf-ftp/iana/yang-parameters/ /a/www/ietf-ftp/yang/ianamod/ # Get Yang models from Yangcatalog. -rsync -avzq rsync://rsync.yangcatalog.org:10873/yangdeps /a/www/ietf-ftp/yang/catalogmod/ +#rsync -avzq rsync://rsync.yangcatalog.org:10873/yangdeps /a/www/ietf-ftp/yang/catalogmod/ +/a/www/ietf-datatracker/scripts/sync_to_yangcatalog # Populate the yang repositories $DTDIR/ietf/manage.py populate_yang_model_dirs -v0 @@ -42,27 +43,9 @@ $DTDIR/ietf/manage.py populate_yang_model_dirs -v0 # Re-run yang checks on active documents $DTDIR/ietf/manage.py run_yang_model_checks -v0 -# Expire Internet-Drafts -# Enable when removed from /a/www/ietf-datatracker/scripts/Cron-runner: -$DTDIR/ietf/bin/expire-ids - -# Send nomcom reminders about nomination acceptance and questionnaires -$DTDIR/ietf/manage.py send_reminders - # Expire last calls # Enable when removed from /a/www/ietf-datatracker/scripts/Cron-runner: $DTDIR/ietf/bin/expire-last-calls -# Run an extended version of the rfc editor update, to catch changes -# with backdated timestamps -# Enable when removed from /a/www/ietf-datatracker/scripts/Cron-runner: -$DTDIR/ietf/bin/rfc-editor-index-updates -d 1969-01-01 - -# Fetch meeting attendance data from ietf.org/registration/attendees -$DTDIR/ietf/manage.py fetch_meeting_attendance --latest 2 - -# Send reminders originating from the review app -$DTDIR/ietf/bin/send-review-reminders - # Purge older PersonApiKeyEvents $DTDIR/ietf/manage.py purge_old_personal_api_key_events 14 diff --git a/bin/every15m b/bin/every15m deleted file mode 100755 index c0b0752f6a..0000000000 --- a/bin/every15m +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -# datatracker jobs to run every 15 minutes -# -# This script is expected to be triggered by cron from -# /etc/cron.d/datatracker - -export LANG=en_US.UTF-8 -export PYTHONIOENCODING=utf-8 - -DTDIR=/a/www/ietf-datatracker/web -cd $DTDIR/ - -# Set up the virtual environment -source $DTDIR/env/bin/activate - -logger -p user.info -t cron "Running $DTDIR/bin/every15m" - -# Send mail scheduled to go out at certain times -$DTDIR/ietf/bin/send-scheduled-mail all - -# Reparse the last _year_ of RFC index entries -# (which is the default if -d is not provided) -# until https://github.com/ietf-tools/datatracker/issues/3734 -# is addressed. -# This takes about 20s on production as of 2022-08-11 -$DTDIR/ietf/bin/rfc-editor-index-updates - diff --git a/bin/hourly b/bin/hourly index 77310302ce..9478bec119 100755 --- a/bin/hourly +++ b/bin/hourly @@ -19,72 +19,11 @@ source $DTDIR/env/bin/activate logger -p user.info -t cron "Running $DTDIR/bin/hourly" -# *** Enable when removed from /a/www/ietf-datatracker/scripts/Cron-runner: *** - -# # Update community lists. Remove once the community rewrite (will be around 6.20.0 ) -# $DTDIR/ietf/manage.py update_community_lists -# -# # Polling backup for iana and rfc-editory post APIs -$DTDIR/ietf/bin/iana-changes-updates -$DTDIR/ietf/bin/iana-protocols-updates -# $DTDIR/ietf/bin/rfc-editor-index-updates -# $DTDIR/ietf/bin/rfc-editor-queue-updates -# -# # Generate alias and virtual files for draft email aliases -# $DTDIR/ietf/bin/generate-draft-aliases && \ -# ( cd /a/postfix; /usr/sbin/postalias -o draft-aliases; ) && \ -# ( cd /a/postfix; /usr/sbin/postmap -o draft-virtual; ) -# -# # Generate alias and virtual files for group email aliases -# $DTDIR/ietf/bin/generate-wg-aliases && \ -# ( cd /a/postfix; /usr/sbin/postalias -o group-aliases; ) && \ -# ( cd /a/postfix; /usr/sbin/postmap -o group-virtual; ) -# # Generate some static files ID=/a/ietfdata/doc/draft/repository DERIVED=/a/ietfdata/derived DOWNLOAD=/a/www/www6s/download -export TMPDIR=/a/tmp - -TMPFILE1=`mktemp` || exit 1 -TMPFILE2=`mktemp` || exit 1 -TMPFILE3=`mktemp` || exit 1 -TMPFILE4=`mktemp` || exit 1 -TMPFILE5=`mktemp` || exit 1 -TMPFILE6=`mktemp` || exit 1 -TMPFILE7=`mktemp` || exit 1 -TMPFILE8=`mktemp` || exit 1 -TMPFILE9=`mktemp` || exit 1 -TMPFILEA=`mktemp` || exit 1 -TMPFILEB=`mktemp` || exit 1 - -chmod a+r $TMPFILE1 $TMPFILE2 $TMPFILE3 $TMPFILE4 $TMPFILE5 $TMPFILE6 $TMPFILE7 $TMPFILE8 $TMPFILE9 $TMPFILEA $TMPFILEB - -python -m ietf.idindex.generate_all_id_txt >> $TMPFILE1 -python -m ietf.idindex.generate_id_index_txt >> $TMPFILE2 -python -m ietf.idindex.generate_id_abstracts_txt >> $TMPFILE3 -cp $TMPFILE1 $TMPFILE4 -cp $TMPFILE2 $TMPFILE5 -cp $TMPFILE3 $TMPFILE6 -cp $TMPFILE1 $TMPFILE8 -cp $TMPFILE2 $TMPFILE9 -cp $TMPFILE3 $TMPFILEA -python -m ietf.idindex.generate_all_id2_txt >> $TMPFILE7 -cp $TMPFILE7 $TMPFILEB - -mv $TMPFILE1 $ID/all_id.txt -mv $TMPFILE2 $ID/1id-index.txt -mv $TMPFILE3 $ID/1id-abstracts.txt -mv $TMPFILE4 $DOWNLOAD/id-all.txt -mv $TMPFILE5 $DOWNLOAD/id-index.txt -mv $TMPFILE6 $DOWNLOAD/id-abstract.txt -mv $TMPFILE7 $ID/all_id2.txt -mv $TMPFILE8 $DERIVED/all_id.txt -mv $TMPFILE9 $DERIVED/1id-index.txt -mv $TMPFILEA $DERIVED/1id-abstracts.txt -mv $TMPFILEB $DERIVED/all_id2.txt - $DTDIR/ietf/manage.py generate_idnits2_rfc_status $DTDIR/ietf/manage.py generate_idnits2_rfcs_obsoleted diff --git a/bin/mm_hourly b/bin/mm_hourly index 0d1da2e572..e371fd611b 100755 --- a/bin/mm_hourly +++ b/bin/mm_hourly @@ -21,4 +21,3 @@ source $DTDIR/env/bin/activate logger -p user.info -t cron "Running $DTDIR/bin/mm_hourly" -$DTDIR/ietf/manage.py import_mailman_listinfo diff --git a/bin/weekly b/bin/weekly index cca8403fd4..8e01c273ca 100755 --- a/bin/weekly +++ b/bin/weekly @@ -20,6 +20,3 @@ logger -p user.info -t cron "Running $DTDIR/bin/weekly" $DTDIR/ietf/manage.py send_apikey_usage_emails -# Send notifications about coming expirations -$DTDIR/ietf/bin/notify-expirations - diff --git a/client/agenda/Agenda.vue b/client/agenda/Agenda.vue index 0212fc8cb6..34a3751f23 100644 --- a/client/agenda/Agenda.vue +++ b/client/agenda/Agenda.vue @@ -58,14 +58,17 @@ n-button( :type='agendaStore.isTimezoneMeeting ? `primary` : `default`' @click='setTimezone(`meeting`)' + :text-color='agendaStore.isTimezoneMeeting ? `#FFF` : null' ) Meeting n-button( :type='agendaStore.isTimezoneLocal ? `primary` : `default`' @click='setTimezone(`local`)' + :text-color='agendaStore.isTimezoneLocal ? `#FFF` : null' ) Local n-button( :type='agendaStore.timezone === `UTC` ? `primary` : `default`' @click='setTimezone(`UTC`)' + :text-color='agendaStore.timezone === `UTC` ? `#FFF` : null' ) UTC n-select.agenda-timezone-ddn( v-if='siteStore.viewport > 1250' diff --git a/client/agenda/AgendaQuickAccess.vue b/client/agenda/AgendaQuickAccess.vue index 5952e6de1c..99adc1027e 100644 --- a/client/agenda/AgendaQuickAccess.vue +++ b/client/agenda/AgendaQuickAccess.vue @@ -62,7 +62,8 @@ n-button.mt-2( id='agenda-quickaccess-calview-btn' block - color='#6c757d' + color='#6f42c1' + text-color='#FFF' size='large' strong @click='agendaStore.$patch({ calendarShown: true })' @@ -78,8 +79,8 @@ n-button.mt-2( id='agenda-quickaccess-addtocal-btn' block - secondary - color='#6c757d' + :color='siteStore.theme === `dark` ? `rgba(111, 66, 193, .3)` : `#e2d9f3`' + :text-color='siteStore.theme === `dark` ? `#e2d9f3` : `#59359a`' size='large' strong ) diff --git a/client/agenda/AgendaScheduleCalendar.vue b/client/agenda/AgendaScheduleCalendar.vue index d8a30ca4ad..9b56b7f5a7 100644 --- a/client/agenda/AgendaScheduleCalendar.vue +++ b/client/agenda/AgendaScheduleCalendar.vue @@ -11,14 +11,17 @@ n-drawer(v-model:show='isShown', placement='bottom', :height='state.drawerHeight n-button( :type='agendaStore.isTimezoneMeeting ? `primary` : `default`' @click='setTimezone(`meeting`)' + :text-color='agendaStore.isTimezoneMeeting ? `#FFF` : null' ) Meeting n-button( :type='agendaStore.isTimezoneLocal ? `primary` : `default`' @click='setTimezone(`local`)' + :text-color='agendaStore.isTimezoneLocal ? `#FFF` : null' ) Local n-button( :type='agendaStore.timezone === `UTC` ? `primary` : `default`' @click='setTimezone(`UTC`)' + :text-color='agendaStore.timezone === `UTC` ? `#FFF` : null' ) UTC n-divider(vertical) n-button.me-2( @@ -32,7 +35,7 @@ n-drawer(v-model:show='isShown', placement='bottom', :height='state.drawerHeight n-badge.ms-2(:value='agendaStore.selectedCatSubs.length', processing) n-button( ghost - color='gray' + :color='siteStore.theme === `dark` ? `#e35d6a` : `gray`' strong @click='close' ) diff --git a/client/components/Polls.vue b/client/components/Polls.vue index f5023995a3..30cc9e8f36 100644 --- a/client/components/Polls.vue +++ b/client/components/Polls.vue @@ -3,9 +3,11 @@ n-data-table( v-if='state.items.length > 0' :data='state.items' - :columns='columns' + :columns='state.columns' striped ) + span.text-danger(v-else-if='state.errMessage') + em {{ state.errMessage }} span.text-body-secondary(v-else) em No polls available. @@ -13,9 +15,8 @@ diff --git a/debug.py b/debug.py index bf34367cce..e6aed2e9bd 100644 --- a/debug.py +++ b/debug.py @@ -55,7 +55,7 @@ def fix(s,n=64): if len(s) > n+3: s = s[:n]+"..." return s - def wrap(fn, *params,**kwargs): + def wrap(*params,**kwargs): call = wrap.callcount = wrap.callcount + 1 indent = ' ' * _report_indent[0] @@ -81,8 +81,8 @@ def wrap(fn, *params,**kwargs): return ret wrap.callcount = 0 if debug: - from decorator import decorator - return decorator(wrap, fn) + from functools import update_wrapper + return update_wrapper(wrap, fn) else: return fn @@ -119,7 +119,7 @@ def clock(s): def time(fn): """Decorator to print timing information about a function call. """ - def wrap(fn, *params,**kwargs): + def wrap(*params,**kwargs): indent = ' ' * _report_indent[0] fc = "%s.%s()" % (fn.__module__, fn.__name__,) @@ -132,8 +132,8 @@ def wrap(fn, *params,**kwargs): return ret wrap.callcount = 0 if debug: - from decorator import decorator - return decorator(wrap, fn) + from functools import update_wrapper + return update_wrapper(wrap, fn) else: return fn @@ -190,7 +190,7 @@ def type(name): value = eval(name, frame.f_globals, frame.f_locals) indent = ' ' * (_report_indent[0]) sys.stderr.write("%s%s: %s\n" % (indent, name, value)) - + def say(s): if debug: indent = ' ' * (_report_indent[0]) @@ -205,11 +205,11 @@ def wrapper(*args, **kwargs): prof.dump_stats(datafn) return retval if debug: - from decorator import decorator - return decorator(wrapper, fn) + from functools import update_wrapper + return update_wrapper(wrapper, fn) else: return fn - + def traceback(levels=None): if debug: indent = ' ' * (_report_indent[0]) diff --git a/dev/INSTALL b/dev/INSTALL index 15c7472972..9a8004010d 100644 --- a/dev/INSTALL +++ b/dev/INSTALL @@ -62,9 +62,11 @@ General Instructions for Deployment of a New Release docker-compose down - 9. Stop the datatracker + 9. Stop the datatracker and remove the web link so cron or other applications + don't run code in the older deployment. sudo systemctl stop datatracker.socket datatracker.service + rm /a/www/ietf-datatracker/web 10. Return to the release directory and run migrations as wwwrun: @@ -77,7 +79,7 @@ General Instructions for Deployment of a New Release 11. Back out one directory level, then re-point the 'web' symlink:: cd .. - rm ./web; ln -s ${releasenumber} web + ln -s ${releasenumber} web 12. Start the datatracker service (it is no longer necessary to restart apache) :: diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile new file mode 100644 index 0000000000..2ffce35495 --- /dev/null +++ b/dev/build/Dockerfile @@ -0,0 +1,17 @@ +FROM ghcr.io/ietf-tools/datatracker-app-base:latest +LABEL maintainer="IETF Tools Team " + +ENV DEBIAN_FRONTEND=noninteractive + +COPY . . +COPY ./dev/build/start.sh ./start.sh +RUN pip3 --disable-pip-version-check --no-cache-dir install -r requirements.txt +RUN chmod +x start.sh && \ + chmod +x docker/scripts/app-create-dirs.sh && \ + sh ./docker/scripts/app-create-dirs.sh + +VOLUME [ "/assets" ] + +EXPOSE 8000 + +CMD ["./start.sh"] \ No newline at end of file diff --git a/dev/build/collectstatics.sh b/dev/build/collectstatics.sh new file mode 100644 index 0000000000..44f1c608a9 --- /dev/null +++ b/dev/build/collectstatics.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Copy temp local settings +cp dev/build/settings_local_collectstatics.py ietf/settings_local.py + +# Install Python dependencies +pip --disable-pip-version-check --no-cache-dir install -r requirements.txt + +# Collect statics +ietf/manage.py collectstatic + +# Delete temp local settings +rm ietf/settings_local.py \ No newline at end of file diff --git a/dev/deploy/exclude-patterns.txt b/dev/build/exclude-patterns.txt similarity index 100% rename from dev/deploy/exclude-patterns.txt rename to dev/build/exclude-patterns.txt diff --git a/dev/build/settings_local_collectstatics.py b/dev/build/settings_local_collectstatics.py new file mode 100644 index 0000000000..016b3f934a --- /dev/null +++ b/dev/build/settings_local_collectstatics.py @@ -0,0 +1,8 @@ +# Copyright The IETF Trust 2007-2019, All Rights Reserved +# -*- coding: utf-8 -*- + +from ietf import __version__ +from ietf.settings import * # pyflakes:ignore + +STATIC_URL = "https://static.ietf.org/dt/%s/"%__version__ +STATIC_ROOT = os.path.abspath(BASE_DIR + "/../static/") diff --git a/dev/build/start.sh b/dev/build/start.sh new file mode 100644 index 0000000000..ef64ca7b30 --- /dev/null +++ b/dev/build/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +echo "Running Datatracker checks..." +./ietf/manage.py check + +echo "Running Datatracker migrations..." +./ietf/manage.py migrate --settings=settings_local + +echo "Starting Datatracker..." +./ietf/manage.py runserver 0.0.0.0:8000 --settings=settings_local diff --git a/dev/coverage-action/package-lock.json b/dev/coverage-action/package-lock.json index 53ceb53d48..f89bd58386 100644 --- a/dev/coverage-action/package-lock.json +++ b/dev/coverage-action/package-lock.json @@ -11,18 +11,18 @@ "dependencies": { "@actions/core": "1.10.1", "@actions/github": "6.0.0", - "chart.js": "3.5.1", + "chart.js": "3.9.1", "chartjs-node-canvas": "4.1.6", "lodash": "4.17.21", "luxon": "3.4.4" }, "devDependencies": { - "eslint": "8.53.0", + "eslint": "8.57.0", "eslint-config-standard": "17.1.0", - "eslint-plugin-import": "2.29.0", + "eslint-plugin-import": "2.29.1", "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "6.1.1", - "npm-check-updates": "16.14.6" + "npm-check-updates": "16.14.17" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -98,9 +98,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", - "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", @@ -121,9 +121,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz", - "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -138,13 +138,13 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -165,9 +165,9 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, "node_modules/@isaacs/cliui": { @@ -737,9 +737,9 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -1341,9 +1341,9 @@ } }, "node_modules/chart.js": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz", - "integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ==" + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", + "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==" }, "node_modules/chartjs-node-canvas": { "version": "4.1.6", @@ -1841,16 +1841,16 @@ } }, "node_modules/eslint": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz", - "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.53.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -1990,9 +1990,9 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", - "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, "dependencies": { "array-includes": "^3.1.7", @@ -2011,7 +2011,7 @@ "object.groupby": "^1.0.1", "object.values": "^1.1.7", "semver": "^6.3.1", - "tsconfig-paths": "^3.14.2" + "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" @@ -2666,9 +2666,9 @@ } }, "node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -4079,9 +4079,9 @@ } }, "node_modules/npm-check-updates": { - "version": "16.14.6", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.6.tgz", - "integrity": "sha512-sJ6w4AmSDP7YzBXah94Ul2JhiIbjBDfx9XYgib15um2wtiQkOyjE7Lov3MNUSQ84Ry7T81mE4ynMbl/mGbK4HQ==", + "version": "16.14.17", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.17.tgz", + "integrity": "sha512-ElnDdXKe60f8S6RhzFeaGuH2TFJmt2cU2HjLdowldabdm27nWFCxV2ebeP3xGbQkzp2+RPDQNdW9HqU1lcY8ag==", "dev": true, "dependencies": { "chalk": "^5.3.0", @@ -5710,9 +5710,9 @@ "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "node_modules/tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", @@ -6349,9 +6349,9 @@ "dev": true }, "@eslint/eslintrc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", - "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "requires": { "ajv": "^6.12.4", @@ -6366,9 +6366,9 @@ } }, "@eslint/js": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz", - "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true }, "@fastify/busboy": { @@ -6377,13 +6377,13 @@ "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==" }, "@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "requires": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" } }, @@ -6394,9 +6394,9 @@ "dev": true }, "@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, "@isaacs/cliui": { @@ -6828,9 +6828,9 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true }, "acorn-jsx": { @@ -7263,9 +7263,9 @@ } }, "chart.js": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz", - "integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ==" + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", + "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==" }, "chartjs-node-canvas": { "version": "4.1.6", @@ -7631,16 +7631,16 @@ "dev": true }, "eslint": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz", - "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.53.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -7747,9 +7747,9 @@ } }, "eslint-plugin-import": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", - "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, "requires": { "array-includes": "^3.1.7", @@ -7768,7 +7768,7 @@ "object.groupby": "^1.0.1", "object.values": "^1.1.7", "semver": "^6.3.1", - "tsconfig-paths": "^3.14.2" + "tsconfig-paths": "^3.15.0" }, "dependencies": { "debug": { @@ -8228,9 +8228,9 @@ } }, "globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "requires": { "type-fest": "^0.20.2" @@ -9249,9 +9249,9 @@ } }, "npm-check-updates": { - "version": "16.14.6", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.6.tgz", - "integrity": "sha512-sJ6w4AmSDP7YzBXah94Ul2JhiIbjBDfx9XYgib15um2wtiQkOyjE7Lov3MNUSQ84Ry7T81mE4ynMbl/mGbK4HQ==", + "version": "16.14.17", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.17.tgz", + "integrity": "sha512-ElnDdXKe60f8S6RhzFeaGuH2TFJmt2cU2HjLdowldabdm27nWFCxV2ebeP3xGbQkzp2+RPDQNdW9HqU1lcY8ag==", "dev": true, "requires": { "chalk": "^5.3.0", @@ -10407,9 +10407,9 @@ "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "requires": { "@types/json5": "^0.0.29", diff --git a/dev/coverage-action/package.json b/dev/coverage-action/package.json index b5279df694..4738139600 100644 --- a/dev/coverage-action/package.json +++ b/dev/coverage-action/package.json @@ -8,17 +8,17 @@ "dependencies": { "@actions/core": "1.10.1", "@actions/github": "6.0.0", - "chart.js": "3.5.1", + "chart.js": "3.9.1", "chartjs-node-canvas": "4.1.6", "lodash": "4.17.21", "luxon": "3.4.4" }, "devDependencies": { - "eslint": "8.53.0", + "eslint": "8.57.0", "eslint-config-standard": "17.1.0", - "eslint-plugin-import": "2.29.0", + "eslint-plugin-import": "2.29.1", "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "6.1.1", - "npm-check-updates": "16.14.6" + "npm-check-updates": "16.14.17" } } diff --git a/dev/deploy-to-container/cli.js b/dev/deploy-to-container/cli.js index 1c3d466286..2912c98fdf 100644 --- a/dev/deploy-to-container/cli.js +++ b/dev/deploy-to-container/cli.js @@ -67,6 +67,7 @@ async function main () { .replace('__DBHOST__', `dt-db-${branch}`) .replace('__SECRETKEY__', nanoid(36)) .replace('__MQCONNSTR__', `amqp://datatracker:${mqKey}@dt-mq-${branch}/dt`) + .replace('__HOSTNAME__', hostname) ) await fs.copy(path.join(basePath, 'docker/scripts/app-create-dirs.sh'), path.join(releasePath, 'app-create-dirs.sh')) await fs.copy(path.join(basePath, 'dev/deploy-to-container/start.sh'), path.join(releasePath, 'start.sh')) @@ -175,6 +176,9 @@ async function main () { Image: 'ghcr.io/ietf-tools/datatracker-db:latest', name: `dt-db-${branch}`, Hostname: `dt-db-${branch}`, + Labels: { + ...argv.nodbrefresh === 'true' && { nodbrefresh: '1' } + }, HostConfig: { NetworkMode: 'shared', RestartPolicy: { @@ -194,6 +198,9 @@ async function main () { Env: [ `CELERY_PASSWORD=${mqKey}` ], + Labels: { + ...argv.nodbrefresh === 'true' && { nodbrefresh: '1' } + }, HostConfig: { Memory: 4 * (1024 ** 3), // in bytes NetworkMode: 'shared', @@ -222,6 +229,9 @@ async function main () { `CELERY_ROLE=${conConf.role}`, 'UPDATE_REQUIREMENTS_FROM=requirements.txt' ], + Labels: { + ...argv.nodbrefresh === 'true' && { nodbrefresh: '1' } + }, HostConfig: { Binds: [ 'dt-assets:/assets', @@ -245,7 +255,7 @@ async function main () { name: `dt-app-${branch}`, Hostname: `dt-app-${branch}`, Env: [ - `LETSENCRYPT_HOST=${hostname}`, + // `LETSENCRYPT_HOST=${hostname}`, `VIRTUAL_HOST=${hostname}`, `VIRTUAL_PORT=8000`, `PGHOST=dt-db-${branch}` @@ -254,7 +264,8 @@ async function main () { appversion: `${argv.appversion}` ?? '0.0.0', commit: `${argv.commit}` ?? 'unknown', ghrunid: `${argv.ghrunid}` ?? '0', - hostname + hostname, + ...argv.nodbrefresh === 'true' && { nodbrefresh: '1' } }, HostConfig: { Binds: [ diff --git a/dev/deploy-to-container/package-lock.json b/dev/deploy-to-container/package-lock.json index 7ea0235073..113c6e8329 100644 --- a/dev/deploy-to-container/package-lock.json +++ b/dev/deploy-to-container/package-lock.json @@ -6,9 +6,9 @@ "": { "name": "deploy-to-container", "dependencies": { - "dockerode": "^4.0.0", - "fs-extra": "^11.1.1", - "nanoid": "5.0.3", + "dockerode": "^4.0.2", + "fs-extra": "^11.2.0", + "nanoid": "5.0.6", "nanoid-dictionary": "5.0.0-beta.1", "slugify": "1.6.6", "tar": "^6.2.0", @@ -154,26 +154,26 @@ } }, "node_modules/docker-modem": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.1.tgz", - "integrity": "sha512-vqrE/nrweCyzmCpVpdFRC41qS+tfTF+IoUKlTZr52O82urbUqdfyJBGWMvT01pYUprWepLr8IkyVTEWJKRTQSg==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz", + "integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==", "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", - "ssh2": "^1.11.0" + "ssh2": "^1.15.0" }, "engines": { "node": ">= 8.0" } }, "node_modules/dockerode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.0.tgz", - "integrity": "sha512-3LF7/3MPz5+9RsUo91rD0MCcx0yxjC9bnbtgtVjOLKyKxlZSJ7/Kk3OPAgARlwlWHqXwAGYhmkAHYx7IwD0tJQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz", + "integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==", "dependencies": { "@balena/dockerignore": "^1.0.2", - "docker-modem": "^5.0.0", + "docker-modem": "^5.0.3", "tar-fs": "~2.0.1" }, "engines": { @@ -207,9 +207,9 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, "node_modules/fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -337,9 +337,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.3.tgz", - "integrity": "sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz", + "integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==", "funding": [ { "type": "github", @@ -434,9 +434,9 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==" }, "node_modules/ssh2": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz", - "integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", "hasInstallScript": true, "dependencies": { "asn1": "^0.2.6", @@ -446,8 +446,8 @@ "node": ">=10.16.0" }, "optionalDependencies": { - "cpu-features": "~0.0.8", - "nan": "^2.17.0" + "cpu-features": "~0.0.9", + "nan": "^2.18.0" } }, "node_modules/string_decoder": { @@ -744,23 +744,23 @@ } }, "docker-modem": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.1.tgz", - "integrity": "sha512-vqrE/nrweCyzmCpVpdFRC41qS+tfTF+IoUKlTZr52O82urbUqdfyJBGWMvT01pYUprWepLr8IkyVTEWJKRTQSg==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz", + "integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==", "requires": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", - "ssh2": "^1.11.0" + "ssh2": "^1.15.0" } }, "dockerode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.0.tgz", - "integrity": "sha512-3LF7/3MPz5+9RsUo91rD0MCcx0yxjC9bnbtgtVjOLKyKxlZSJ7/Kk3OPAgARlwlWHqXwAGYhmkAHYx7IwD0tJQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz", + "integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==", "requires": { "@balena/dockerignore": "^1.0.2", - "docker-modem": "^5.0.0", + "docker-modem": "^5.0.3", "tar-fs": "~2.0.1" } }, @@ -788,9 +788,9 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, "fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "requires": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -878,9 +878,9 @@ "optional": true }, "nanoid": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.3.tgz", - "integrity": "sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==" + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz", + "integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==" }, "nanoid-dictionary": { "version": "5.0.0-beta.1", @@ -940,14 +940,14 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==" }, "ssh2": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz", - "integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", "requires": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2", - "cpu-features": "~0.0.8", - "nan": "^2.17.0" + "cpu-features": "~0.0.9", + "nan": "^2.18.0" } }, "string_decoder": { diff --git a/dev/deploy-to-container/package.json b/dev/deploy-to-container/package.json index 509e578663..f4b7320e54 100644 --- a/dev/deploy-to-container/package.json +++ b/dev/deploy-to-container/package.json @@ -2,9 +2,9 @@ "name": "deploy-to-container", "type": "module", "dependencies": { - "dockerode": "^4.0.0", - "fs-extra": "^11.1.1", - "nanoid": "5.0.3", + "dockerode": "^4.0.2", + "fs-extra": "^11.2.0", + "nanoid": "5.0.6", "nanoid-dictionary": "5.0.0-beta.1", "slugify": "1.6.6", "tar": "^6.2.0", diff --git a/dev/deploy-to-container/refresh.js b/dev/deploy-to-container/refresh.js index 827d85de6c..7ea13c885a 100644 --- a/dev/deploy-to-container/refresh.js +++ b/dev/deploy-to-container/refresh.js @@ -24,7 +24,8 @@ async function main () { const containersToRestart = [] for (const container of containers) { if ( - container.Names.some(n => n.startsWith('/dt-db-')) + container.Names.some(n => n.startsWith('/dt-db-')) && + container.Labels?.nodbrefresh !== '1' ) { console.info(`Terminating DB container ${container.Id}...`) dbContainersToCreate.push(container.Names.find(n => n.startsWith('/dt-db-')).substring(1)) @@ -37,9 +38,11 @@ async function main () { v: true }) } else if ( - container.Names.some(n => n.startsWith('/dt-app-')) || - container.Names.some(n => n.startsWith('/dt-celery-')) || - container.Names.some(n => n.startsWith('/dt-beat-')) + ( + container.Names.some(n => n.startsWith('/dt-app-')) || + container.Names.some(n => n.startsWith('/dt-celery-')) || + container.Names.some(n => n.startsWith('/dt-beat-')) + ) && container.Labels?.nodbrefresh !== '1' ) { if (container.State === 'running') { const appContainer = dock.getContainer(container.Id) diff --git a/dev/deploy-to-container/settings_local.py b/dev/deploy-to-container/settings_local.py index 4b24f83b33..15b44433ea 100644 --- a/dev/deploy-to-container/settings_local.py +++ b/dev/deploy-to-container/settings_local.py @@ -69,3 +69,6 @@ SLIDE_STAGING_PATH = '/test/staging/' DE_GFM_BINARY = '/usr/local/bin/de-gfm' + +# OIDC configuration +SITE_URL = 'https://__HOSTNAME__' diff --git a/dev/deploy-to-container/start.sh b/dev/deploy-to-container/start.sh index 271c54a43e..2c83d6970c 100644 --- a/dev/deploy-to-container/start.sh +++ b/dev/deploy-to-container/start.sh @@ -38,8 +38,5 @@ echo "Running Datatracker checks..." echo "Running Datatracker migrations..." /usr/local/bin/python ./ietf/manage.py migrate --settings=settings_local -echo "Syncing with the rfc-index" -./ietf/bin/rfc-editor-index-updates -d 1969-01-01 - echo "Starting Datatracker..." ./ietf/manage.py runserver 0.0.0.0:8000 --settings=settings_local diff --git a/dev/deploy/build.sh b/dev/deploy/build.sh deleted file mode 100644 index a802acb46b..0000000000 --- a/dev/deploy/build.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -echo "Compiling native node packages..." -yarn rebuild -echo "Packaging static assets..." -if [ "${SHOULD_DEPLOY}" = "true" ]; then - yarn build --base=https://www.ietf.org/lib/dt/$PKG_VERSION/ -else - yarn build -fi -yarn legacy:build diff --git a/dev/diff/package-lock.json b/dev/diff/package-lock.json index 1cd1632063..b8f007baf1 100644 --- a/dev/diff/package-lock.json +++ b/dev/diff/package-lock.json @@ -7,10 +7,10 @@ "name": "diff", "dependencies": { "chalk": "^5.3.0", - "dockerode": "^4.0.0", + "dockerode": "^4.0.2", "enquirer": "^2.4.1", "extract-zip": "^2.0.1", - "fs-extra": "^11.1.1", + "fs-extra": "^11.2.0", "got": "^13.0.0", "keypress": "^0.2.1", "listr2": "^6.6.1", @@ -381,26 +381,26 @@ } }, "node_modules/docker-modem": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.1.tgz", - "integrity": "sha512-vqrE/nrweCyzmCpVpdFRC41qS+tfTF+IoUKlTZr52O82urbUqdfyJBGWMvT01pYUprWepLr8IkyVTEWJKRTQSg==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz", + "integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==", "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", - "ssh2": "^1.11.0" + "ssh2": "^1.15.0" }, "engines": { "node": ">= 8.0" } }, "node_modules/dockerode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.0.tgz", - "integrity": "sha512-3LF7/3MPz5+9RsUo91rD0MCcx0yxjC9bnbtgtVjOLKyKxlZSJ7/Kk3OPAgARlwlWHqXwAGYhmkAHYx7IwD0tJQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz", + "integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==", "dependencies": { "@balena/dockerignore": "^1.0.2", - "docker-modem": "^5.0.0", + "docker-modem": "^5.0.3", "tar-fs": "~2.0.1" }, "engines": { @@ -505,9 +505,9 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, "node_modules/fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1144,9 +1144,9 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==" }, "node_modules/ssh2": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz", - "integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", "hasInstallScript": true, "dependencies": { "asn1": "^0.2.6", @@ -1156,8 +1156,8 @@ "node": ">=10.16.0" }, "optionalDependencies": { - "cpu-features": "~0.0.8", - "nan": "^2.17.0" + "cpu-features": "~0.0.9", + "nan": "^2.18.0" } }, "node_modules/string_decoder": { @@ -1621,23 +1621,23 @@ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==" }, "docker-modem": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.1.tgz", - "integrity": "sha512-vqrE/nrweCyzmCpVpdFRC41qS+tfTF+IoUKlTZr52O82urbUqdfyJBGWMvT01pYUprWepLr8IkyVTEWJKRTQSg==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz", + "integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==", "requires": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", - "ssh2": "^1.11.0" + "ssh2": "^1.15.0" } }, "dockerode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.0.tgz", - "integrity": "sha512-3LF7/3MPz5+9RsUo91rD0MCcx0yxjC9bnbtgtVjOLKyKxlZSJ7/Kk3OPAgARlwlWHqXwAGYhmkAHYx7IwD0tJQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz", + "integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==", "requires": { "@balena/dockerignore": "^1.0.2", - "docker-modem": "^5.0.0", + "docker-modem": "^5.0.3", "tar-fs": "~2.0.1" } }, @@ -1718,9 +1718,9 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, "fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "requires": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2136,14 +2136,14 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==" }, "ssh2": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz", - "integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", "requires": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2", - "cpu-features": "~0.0.8", - "nan": "^2.17.0" + "cpu-features": "~0.0.9", + "nan": "^2.18.0" } }, "string_decoder": { diff --git a/dev/diff/package.json b/dev/diff/package.json index 4d1a562a06..cd59f2655d 100644 --- a/dev/diff/package.json +++ b/dev/diff/package.json @@ -3,10 +3,10 @@ "type": "module", "dependencies": { "chalk": "^5.3.0", - "dockerode": "^4.0.0", + "dockerode": "^4.0.2", "enquirer": "^2.4.1", "extract-zip": "^2.0.1", - "fs-extra": "^11.1.1", + "fs-extra": "^11.2.0", "got": "^13.0.0", "keypress": "^0.2.1", "listr2": "^6.6.1", diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index 12d03d74d2..4a307c6c62 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -1,146 +1,146 @@ -FROM python:3.9-bullseye -LABEL maintainer="IETF Tools Team " - -ENV DEBIAN_FRONTEND=noninteractive -ENV NODE_MAJOR=16 - -# Update system packages -RUN apt-get update \ - && apt-get -qy upgrade \ - && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 - -# Add Node.js Source -RUN apt-get install -y --no-install-recommends ca-certificates curl gnupg \ - && mkdir -p /etc/apt/keyrings\ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg -RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list - -# Add Docker Source -RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg -RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null - -# Add PostgreSQL Source -RUN echo "deb http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list -RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - - -# Install the packages we need -RUN apt-get update --fix-missing && apt-get install -qy --no-install-recommends \ - apache2-utils \ - apt-file \ - bash \ - build-essential \ - curl \ - default-jdk \ - docker-ce-cli \ - enscript \ - firefox-esr \ - gawk \ - g++ \ - gcc \ - ghostscript \ - git \ - gnupg \ - jq \ - less \ - libcairo2-dev \ - libgtk2.0-0 \ - libgtk-3-0 \ - libnotify-dev \ - libgconf-2-4 \ - libgbm-dev \ - libnss3 \ - libxss1 \ - libasound2 \ - libxtst6 \ - libmagic-dev \ - libmariadb-dev \ - libmemcached-tools \ - locales \ - make \ - mariadb-client \ - memcached \ - nano \ - netcat \ - nodejs \ - pgloader \ - pigz \ - postgresql-client-14 \ - pv \ - python3-ipython \ - ripgrep \ - rsync \ - rsyslog \ - ruby \ - ruby-rubygems \ - unzip \ - wget \ - xauth \ - xvfb \ - yang-tools \ - zsh - -# Install kramdown-rfc2629 (ruby) -RUN gem install kramdown-rfc2629 - -# GeckoDriver -ARG GECKODRIVER_VERSION=latest -RUN GK_VERSION=$(if [ ${GECKODRIVER_VERSION:-latest} = "latest" ]; then echo "0.33.0"; else echo $GECKODRIVER_VERSION; fi) \ - && echo "Using GeckoDriver version: "$GK_VERSION \ - && wget --no-verbose -O /tmp/geckodriver.tar.gz https://github.com/mozilla/geckodriver/releases/download/v$GK_VERSION/geckodriver-v$GK_VERSION-linux64.tar.gz \ - && rm -rf /opt/geckodriver \ - && tar -C /opt -zxf /tmp/geckodriver.tar.gz \ - && rm /tmp/geckodriver.tar.gz \ - && mv /opt/geckodriver /opt/geckodriver-$GK_VERSION \ - && chmod 755 /opt/geckodriver-$GK_VERSION \ - && ln -fs /opt/geckodriver-$GK_VERSION /usr/bin/geckodriver - -# Activate Yarn -RUN corepack enable - -# Get rid of installation files we don't need in the image, to reduce size -RUN apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* - -# "fake" dbus address to prevent errors -# https://github.com/SeleniumHQ/docker-selenium/issues/87 -ENV DBUS_SESSION_BUS_ADDRESS=/dev/null - -# avoid million NPM install messages -ENV npm_config_loglevel warn -# allow installing when the main user is root -ENV npm_config_unsafe_perm true -# disable NPM funding messages -ENV npm_config_fund false - -# Set locale to en_US.UTF-8 -RUN echo "LC_ALL=en_US.UTF-8" >> /etc/environment && \ - echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ - echo "LANG=en_US.UTF-8" > /etc/locale.conf && \ - dpkg-reconfigure locales && \ - locale-gen en_US.UTF-8 && \ - update-locale LC_ALL en_US.UTF-8 -ENV LC_ALL en_US.UTF-8 - -# Install idnits -ADD https://raw.githubusercontent.com/ietf-tools/idnits-mirror/main/idnits /usr/local/bin/ -RUN chmod +rx /usr/local/bin/idnits - -# Turn off rsyslog kernel logging (doesn't work in Docker) -RUN sed -i '/imklog/s/^/#/' /etc/rsyslog.conf - -# Colorize the bash shell -RUN sed -i 's/#force_color_prompt=/force_color_prompt=/' /root/.bashrc - -# Turn off rsyslog kernel logging (doesn't work in Docker) -RUN sed -i '/imklog/s/^/#/' /etc/rsyslog.conf - -# Fetch wait-for utility -ADD https://raw.githubusercontent.com/eficode/wait-for/v2.1.3/wait-for /usr/local/bin/ -RUN chmod +rx /usr/local/bin/wait-for - -# Create assets directory -RUN mkdir -p /assets - -# Create workspace -RUN mkdir -p /workspace -WORKDIR /workspace +FROM python:3.9-bullseye +LABEL maintainer="IETF Tools Team " + +ENV DEBIAN_FRONTEND=noninteractive +ENV NODE_MAJOR=16 + +# Update system packages +RUN apt-get update \ + && apt-get -qy upgrade \ + && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 + +# Add Node.js Source +RUN apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && mkdir -p /etc/apt/keyrings\ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list + +# Add Docker Source +RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg +RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Add PostgreSQL Source +RUN echo "deb http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list +RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - + +# Install the packages we need +RUN apt-get update --fix-missing && apt-get install -qy --no-install-recommends \ + apache2-utils \ + apt-file \ + bash \ + build-essential \ + curl \ + default-jdk \ + docker-ce-cli \ + enscript \ + firefox-esr \ + gawk \ + g++ \ + gcc \ + ghostscript \ + git \ + gnupg \ + jq \ + less \ + libcairo2-dev \ + libgtk2.0-0 \ + libgtk-3-0 \ + libnotify-dev \ + libgconf-2-4 \ + libgbm-dev \ + libnss3 \ + libxss1 \ + libasound2 \ + libxtst6 \ + libmagic-dev \ + libmariadb-dev \ + libmemcached-tools \ + locales \ + make \ + mariadb-client \ + memcached \ + nano \ + netcat \ + nodejs \ + pgloader \ + pigz \ + postgresql-client-14 \ + pv \ + python3-ipython \ + ripgrep \ + rsync \ + rsyslog \ + ruby \ + ruby-rubygems \ + unzip \ + wget \ + xauth \ + xvfb \ + yang-tools \ + zsh + +# Install kramdown-rfc2629 (ruby) +RUN gem install kramdown-rfc2629 + +# GeckoDriver +ARG GECKODRIVER_VERSION=latest +RUN GK_VERSION=$(if [ ${GECKODRIVER_VERSION:-latest} = "latest" ]; then echo "0.34.0"; else echo $GECKODRIVER_VERSION; fi) \ + && echo "Using GeckoDriver version: "$GK_VERSION \ + && wget --no-verbose -O /tmp/geckodriver.tar.gz https://github.com/mozilla/geckodriver/releases/download/v$GK_VERSION/geckodriver-v$GK_VERSION-linux64.tar.gz \ + && rm -rf /opt/geckodriver \ + && tar -C /opt -zxf /tmp/geckodriver.tar.gz \ + && rm /tmp/geckodriver.tar.gz \ + && mv /opt/geckodriver /opt/geckodriver-$GK_VERSION \ + && chmod 755 /opt/geckodriver-$GK_VERSION \ + && ln -fs /opt/geckodriver-$GK_VERSION /usr/bin/geckodriver + +# Activate Yarn +RUN corepack enable + +# Get rid of installation files we don't need in the image, to reduce size +RUN apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* + +# "fake" dbus address to prevent errors +# https://github.com/SeleniumHQ/docker-selenium/issues/87 +ENV DBUS_SESSION_BUS_ADDRESS=/dev/null + +# avoid million NPM install messages +ENV npm_config_loglevel warn +# allow installing when the main user is root +ENV npm_config_unsafe_perm true +# disable NPM funding messages +ENV npm_config_fund false + +# Set locale to en_US.UTF-8 +RUN echo "LC_ALL=en_US.UTF-8" >> /etc/environment && \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \ + echo "LANG=en_US.UTF-8" > /etc/locale.conf && \ + dpkg-reconfigure locales && \ + locale-gen en_US.UTF-8 && \ + update-locale LC_ALL en_US.UTF-8 +ENV LC_ALL en_US.UTF-8 + +# Install idnits +ADD https://raw.githubusercontent.com/ietf-tools/idnits-mirror/main/idnits /usr/local/bin/ +RUN chmod +rx /usr/local/bin/idnits + +# Turn off rsyslog kernel logging (doesn't work in Docker) +RUN sed -i '/imklog/s/^/#/' /etc/rsyslog.conf + +# Colorize the bash shell +RUN sed -i 's/#force_color_prompt=/force_color_prompt=/' /root/.bashrc + +# Turn off rsyslog kernel logging (doesn't work in Docker) +RUN sed -i '/imklog/s/^/#/' /etc/rsyslog.conf + +# Fetch wait-for utility +ADD https://raw.githubusercontent.com/eficode/wait-for/v2.1.3/wait-for /usr/local/bin/ +RUN chmod +rx /usr/local/bin/wait-for + +# Create assets directory +RUN mkdir -p /assets + +# Create workspace +RUN mkdir -p /workspace +WORKDIR /workspace diff --git a/helm/.helmignore b/helm/.helmignore new file mode 100644 index 0000000000..2252590f2e --- /dev/null +++ b/helm/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ \ No newline at end of file diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000000..1c19834c06 --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: datatracker +description: The day-to-day front-end to the IETF database for people who work on IETF standards. +home: https://datatracker.ietf.org +sources: + - https://github.com/ietf-tools/datatracker +maintainers: + - name: IETF Tools Team + email: tools-discuss@ietf.org + url: https://github.com/ietf-tools + +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 1.0.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.0.0" \ No newline at end of file diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl new file mode 100644 index 0000000000..071e9b824c --- /dev/null +++ b/helm/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* + Expand the name of the chart. + */}} +{{- define "datatracker.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "datatracker.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "datatracker.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "datatracker.labels" -}} +helm.sh/chart: {{ include "datatracker.chart" . }} +{{ include "datatracker.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "datatracker.selectorLabels" -}} +app.kubernetes.io/name: {{ include "datatracker.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "datatracker.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "datatracker.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml new file mode 100644 index 0000000000..b47c41a970 --- /dev/null +++ b/helm/templates/deployment.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "datatracker.fullname" . }} + labels: + {{- include "datatracker.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} + selector: + matchLabels: + {{- include "datatracker.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "datatracker.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "datatracker.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ default "latest" .Values.image.tag }}" + imagePullPolicy: {{ default "IfNotPresent" .Values.image.imagePullPolicy }} + env: + {{- if .Values.env }} + {{- toYaml .Values.env | nindent 12 }} + {{- end }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 8000 + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + startupProbe: + {{- toYaml .Values.startupProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/templates/hpa.yaml b/helm/templates/hpa.yaml new file mode 100644 index 0000000000..518f7e23ab --- /dev/null +++ b/helm/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "datatracker.fullname" . }} + labels: + {{- include "datatracker.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "datatracker.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml new file mode 100644 index 0000000000..8d9258cd83 --- /dev/null +++ b/helm/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "datatracker.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=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/__init__.py b/ietf/__init__.py index 59f9802dec..26124c3c67 100644 --- a/ietf/__init__.py +++ b/ietf/__init__.py @@ -6,7 +6,7 @@ # Version must stay in single quotes for automatic CI replace # Don't add patch number here: -__version__ = '11.0.0-dev' +__version__ = '1.0.0-dev' # Release hash must stay in single quotes for automatic CI replace __release_hash__ = '' @@ -17,6 +17,24 @@ # set this to ".p1", ".p2", etc. after patching __patch__ = "" +if __version__ == '1.0.0-dev' and __release_hash__ == '' and __release_branch__ == '': + import subprocess + branch = subprocess.run( + ["/usr/bin/git", "branch", "--show-current"], + capture_output=True, + ).stdout.decode().strip() + git_hash = subprocess.run( + ["/usr/bin/git", "rev-parse", "head"], + capture_output=True, + ).stdout.decode().strip() + rev = subprocess.run( + ["/usr/bin/git", "describe", "--tags", git_hash], + capture_output=True, + ).stdout.decode().strip().split('-', 1)[0] + __version__ = f"{rev}-dev" + __release_branch__ = branch + __release_hash__ = git_hash + # This will make sure the app is always imported when # Django starts so that shared_task will use this app. 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..2310d71d75 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 @@ -30,14 +32,16 @@ from ietf.meeting.models import Session from ietf.nomcom.models import Volunteer, NomCom from ietf.nomcom.factories import NomComFactory, nomcom_kwargs_for_year -from ietf.person.factories import PersonFactory, random_faker -from ietf.person.models import User +from ietf.person.factories import PersonFactory, random_faker, EmailFactory +from ietf.person.models import Email, User from ietf.person.models import PersonalApiKey from ietf.stats.models import MeetingRegistration from ietf.utils.mail import outbox, get_payload_text 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', @@ -215,7 +219,9 @@ def test_api_set_session_video_url(self): event = doc.latest_event() self.assertEqual(event.by, recman) - def test_api_add_session_attendees(self): + def test_api_add_session_attendees_deprecated(self): + # Deprecated test - should be removed when we stop accepting a simple list of user PKs in + # the add_session_attendees() view url = urlreverse('ietf.meeting.views.api_add_session_attendees') otherperson = PersonFactory() recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') @@ -281,6 +287,120 @@ def test_api_add_session_attendees(self): self.assertTrue(session.attended_set.filter(person=recman).exists()) self.assertTrue(session.attended_set.filter(person=otherperson).exists()) + def test_api_add_session_attendees(self): + url = urlreverse("ietf.meeting.views.api_add_session_attendees") + otherperson = PersonFactory() + recmanrole = RoleFactory(group__type_id="ietf", name_id="recman") + recman = recmanrole.person + meeting = MeetingFactory(type_id="ietf") + session = SessionFactory(group__type_id="wg", meeting=meeting) + apikey = PersonalApiKey.objects.create(endpoint=url, person=recman) + + badrole = RoleFactory(group__type_id="ietf", name_id="ad") + badapikey = PersonalApiKey.objects.create(endpoint=url, person=badrole.person) + badrole.person.user.last_login = timezone.now() + badrole.person.user.save() + + # Improper credentials, or method + r = self.client.post(url, {}) + self.assertContains(r, "Missing apikey parameter", status_code=400) + + r = self.client.post(url, {"apikey": badapikey.hash()}) + self.assertContains(r, "Restricted to role: Recording Manager", status_code=403) + + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Too long since last regular login", status_code=400) + + recman.user.last_login = timezone.now() - datetime.timedelta(days=365) + recman.user.save() + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Too long since last regular login", status_code=400) + + recman.user.last_login = timezone.now() + recman.user.save() + r = self.client.get(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Method not allowed", status_code=405) + + recman.user.last_login = timezone.now() + recman.user.save() + + # Malformed requests + r = self.client.post(url, {"apikey": apikey.hash()}) + self.assertContains(r, "Missing attended parameter", status_code=400) + + for baddict in ( + "{}", + '{"bogons;drop table":"bogons;drop table"}', + '{"session_id":"Not an integer;drop table"}', + f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}', + f'{{"session_id":{session.pk},"attendees":"not a list;drop table"}}', + f'{{"session_id":{session.pk},"attendees":[1,2,"not an int;drop table",4]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk}]}}', # no join_time + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time;drop table":"2024-01-01T00:00:00Z]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time":"not a time;drop table"]}}', + # next has no time zone indicator + f'{{"session_id":{session.pk},"attendees":["user_id":{recman.user.pk},"join_time":"2024-01-01T00:00:00"]}}', + f'{{"session_id":{session.pk},"attendees":["user_id":"not an int; drop table","join_time":"2024-01-01T00:00:00Z"]}}', + # Uncomment the next one when the _deprecated version of this test is retired + # f'{{"session_id":{session.pk},"attendees":[{recman.user.pk}, {otherperson.user.pk}]}}', + ): + r = self.client.post(url, {"apikey": apikey.hash(), "attended": baddict}) + self.assertContains(r, "Malformed post", status_code=400) + + bad_session_id = Session.objects.order_by("-pk").first().pk + 1 + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": f'{{"session_id":{bad_session_id},"attendees":[]}}', + }, + ) + self.assertContains(r, "Invalid session", status_code=400) + bad_user_id = User.objects.order_by("-pk").first().pk + 1 + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": f'{{"session_id":{session.pk},"attendees":[{{"user_id":{bad_user_id}, "join_time":"2024-01-01T00:00:00Z"}}]}}', + }, + ) + self.assertContains(r, "Invalid attendee", status_code=400) + + # Reasonable request + r = self.client.post( + url, + { + "apikey": apikey.hash(), + "attended": json.dumps( + { + "session_id": session.pk, + "attendees": [ + { + "user_id": recman.user.pk, + "join_time": "2023-09-03T12:34:56Z", + }, + { + "user_id": otherperson.user.pk, + "join_time": "2023-09-03T03:00:19Z", + }, + ], + } + ), + }, + ) + + self.assertEqual(session.attended_set.count(), 2) + self.assertTrue(session.attended_set.filter(person=recman).exists()) + self.assertEqual( + session.attended_set.get(person=recman).time, + datetime.datetime(2023, 9, 3, 12, 34, 56, tzinfo=datetime.timezone.utc), + ) + self.assertTrue(session.attended_set.filter(person=otherperson).exists()) + self.assertEqual( + session.attended_set.get(person=otherperson).time, + datetime.datetime(2023, 9, 3, 3, 0, 19, tzinfo=datetime.timezone.utc), + ) + def test_api_upload_polls_and_chatlog(self): recmanrole = RoleFactory(group__type_id='ietf', name_id='recman') recmanrole.person.user.last_login = timezone.now() @@ -364,7 +484,7 @@ def test_api_upload_polls_and_chatlog(self): r = self.client.post(url,{'apikey':apikey.hash(),'apidata': f'{{"session_id":{session.pk}, "{type_id}":{content}}}'}) self.assertEqual(r.status_code, 200) - newdoc = session.sessionpresentation_set.get(document__type_id=type_id).document + newdoc = session.presentations.get(document__type_id=type_id).document newdoccontent = get_unicode_document_content(newdoc.name, Path(session.meeting.get_materials_path()) / type_id / newdoc.uploaded_filename) self.assertEqual(json.loads(content), json.loads(newdoccontent)) @@ -450,7 +570,7 @@ def test_deprecated_api_upload_bluesheet(self): 'item': '1', 'bluesheet': bluesheet, }) self.assertContains(r, "Done", status_code=200) - bluesheet = session.sessionpresentation_set.filter(document__type__slug='bluesheets').first().document + bluesheet = session.presentations.filter(document__type__slug='bluesheets').first().document # We've submitted an update; check that the rev is right self.assertEqual(bluesheet.rev, '01') # Check the content @@ -565,7 +685,7 @@ def test_api_upload_bluesheet(self): self.assertContains(r, "Done", status_code=200) bluesheet = ( - session.sessionpresentation_set.filter(document__type__slug="bluesheets") + session.presentations.filter(document__type__slug="bluesheets") .first() .document ) @@ -780,7 +900,96 @@ 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, + ) + @override_settings(APP_API_TOKENS={"ietf.api.views.active_email_list": ["valid-token"]}) + def test_active_email_list(self): + EmailFactory(active=True) # make sure there's at least one active email... + EmailFactory(active=False) # ... and at least one non-active emai + url = urlreverse("ietf.api.views.active_email_list") + r = self.client.post(url, headers={}) + self.assertEqual(r.status_code, 403) + r = self.client.get(url, headers={}) + self.assertEqual(r.status_code, 403) + r = self.client.get(url, headers={"X-Api-Key": "not-the-valid-token"}) + self.assertEqual(r.status_code, 403) + r = self.client.post(url, headers={"X-Api-Key": "not-the-valid-token"}) + self.assertEqual(r.status_code, 403) + r = self.client.post(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 405) + 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") + result = json.loads(r.content) + self.assertCountEqual(result.keys(), ["addresses"]) + self.assertCountEqual(result["addresses"], Email.objects.filter(active=True).values_list("address", flat=True)) class DirectAuthApiTests(TestCase): @@ -1133,3 +1342,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..1adc02a038 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 @@ -45,6 +49,8 @@ # OpenID authentication provider url(r'^openid/$', TemplateView.as_view(template_name='api/openid-issuer.html'), name='ietf.api.urls.oidc_issuer'), url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + # Email alias listing + url(r'^person/email/$', api_views.active_email_list), # Draft submission API url(r'^submit/?$', submit_views.api_submit), # Draft upload API diff --git a/ietf/api/views.py b/ietf/api/views.py index 73b873f5f3..744c6548ae 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 @@ -89,7 +86,7 @@ def get(self, request): 'sendqueue', 'nominee', 'topicfeedbacklastseen', 'alias', 'email', 'apikeys', 'personevent', 'reviewersettings', 'reviewsecretarysettings', 'unavailableperiod', 'reviewwish', 'nextreviewerinteam', 'reviewrequest', 'meetingregistration', 'submissionevent', 'preapproval', - 'user', 'user__communitylist', 'personextresource_set', ] + 'user', 'communitylist', 'personextresource_set', ] return self.json_view(request, filter={'id':person.id}, expand=expand) @@ -453,3 +450,53 @@ 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) + + +@requires_api_token +@csrf_exempt +def active_email_list(request): + if request.method == "GET": + return JsonResponse( + { + "addresses": list(Email.objects.filter(active=True).values_list("address", flat=True)), + } + ) + 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/checks.py b/ietf/checks.py index c823abf118..f66182a027 100644 --- a/ietf/checks.py +++ b/ietf/checks.py @@ -28,26 +28,6 @@ def already_ran(): checks_run.append(name) return False -@checks.register('directories') -def check_cdn_directory_exists(app_configs, **kwargs): - """This checks that the path from which the CDN will serve static files for - this version of the datatracker actually exists. In development and test - mode STATIC_ROOT will normally be just static/, but in production it will be - set to a different part of the file system which is served via CDN, and the - path will contain the datatracker release version. - """ - if already_ran(): - return [] - # - errors = [] - if settings.SERVER_MODE == 'production' and not os.path.exists(settings.STATIC_ROOT): - errors.append(checks.Error( - "The static files directory has not been set up.", - hint="Please run 'ietf/manage.py collectstatic'.", - obj=None, - id='datatracker.E001', - )) - return errors @checks.register('files') def check_group_email_aliases_exists(app_configs, **kwargs): @@ -62,14 +42,14 @@ def check_group_email_aliases_exists(app_configs, **kwargs): if not ok: errors.append(checks.Error( "Found no aliases in the group email aliases file\n'%s'."%settings.GROUP_ALIASES_PATH, - hint="Please run the generate_group_aliases management command to generate them.", + hint="These should be created by the infrastructure using ietf/bin/aliases-from-json.py.", obj=None, id="datatracker.E0002", )) except IOError as e: errors.append(checks.Error( "Could not read group email aliases:\n %s" % e, - hint="Please run the generate_group_aliases management command to generate them.", + hint="These should be created by the infrastructure using ietf/bin/aliases-from-json.py.", obj=None, id="datatracker.E0003", )) @@ -89,14 +69,14 @@ def check_doc_email_aliases_exists(app_configs, **kwargs): if not ok: errors.append(checks.Error( "Found no aliases in the document email aliases file\n'%s'."%settings.DRAFT_VIRTUAL_PATH, - hint="Please run the generate_draft_aliases management command to generate them.", + hint="These should be created by the infrastructure using ietf/bin/aliases-from-json.py.", obj=None, id="datatracker.E0004", )) except IOError as e: errors.append(checks.Error( "Could not read document email aliases:\n %s" % e, - hint="Please run the generate_draft_aliases management command to generate them.", + hint="These should be created by the infrastructure using ietf/bin/aliases-from-json.py.", obj=None, id="datatracker.E0005", )) diff --git a/ietf/community/admin.py b/ietf/community/admin.py index 890819d9d9..4c947ad3f7 100644 --- a/ietf/community/admin.py +++ b/ietf/community/admin.py @@ -7,8 +7,8 @@ from ietf.community.models import CommunityList, SearchRule, EmailSubscription class CommunityListAdmin(admin.ModelAdmin): - list_display = ['id', 'user', 'group'] - raw_id_fields = ['user', 'group', 'added_docs'] + list_display = ['id', 'person', 'group'] + raw_id_fields = ['person', 'group', 'added_docs'] admin.site.register(CommunityList, CommunityListAdmin) class SearchRuleAdmin(admin.ModelAdmin): diff --git a/ietf/community/forms.py b/ietf/community/forms.py index ad85708968..d3fa01dd19 100644 --- a/ietf/community/forms.py +++ b/ietf/community/forms.py @@ -114,14 +114,13 @@ def clean_text(self): class SubscriptionForm(forms.ModelForm): - def __init__(self, user, clist, *args, **kwargs): + def __init__(self, person, clist, *args, **kwargs): self.clist = clist - self.user = user super(SubscriptionForm, self).__init__(*args, **kwargs) self.fields["notify_on"].widget = forms.RadioSelect(choices=self.fields["notify_on"].choices) - self.fields["email"].queryset = self.fields["email"].queryset.filter(person__user=user, active=True).order_by("-primary") + self.fields["email"].queryset = self.fields["email"].queryset.filter(person=person, active=True).order_by("-primary") self.fields["email"].widget = forms.RadioSelect(choices=[t for t in self.fields["email"].choices if t[0]]) if self.fields["email"].queryset: diff --git a/ietf/community/migrations/0004_delete_useless_community_lists.py b/ietf/community/migrations/0004_delete_useless_community_lists.py new file mode 100644 index 0000000000..9f657a3c34 --- /dev/null +++ b/ietf/community/migrations/0004_delete_useless_community_lists.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.9 on 2024-01-05 21:28 + +from django.db import migrations + + +def forward(apps, schema_editor): + CommunityList = apps.get_model("community", "CommunityList") + # As of 2024-01-05, there are 570 personal CommunityLists with a user + # who has no associated Person. None of these has an EmailSubscription, + # so the lists are doing nothing and can be safely deleted. + personal_lists_no_person = CommunityList.objects.exclude( + user__isnull=True + ).filter( + user__person__isnull=True + ) + # Confirm the assumption that none of the lists to be deleted has an EmailSubscription + assert not personal_lists_no_person.filter(emailsubscription__isnull=False).exists() + personal_lists_no_person.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("community", "0003_track_rfcs"), + ] + + operations = [migrations.RunPython(forward)] diff --git a/ietf/community/migrations/0005_user_to_person.py b/ietf/community/migrations/0005_user_to_person.py new file mode 100644 index 0000000000..01d8950edb --- /dev/null +++ b/ietf/community/migrations/0005_user_to_person.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.2 on 2023-06-12 19:35 + +from django.conf import settings +from django.db import migrations +import django.db.models.deletion +import ietf.utils.models + + +def forward(apps, schema_editor): + CommunityList = apps.get_model('community', 'CommunityList') + for clist in CommunityList.objects.all(): + try: + clist.person = clist.user.person + except: + clist.person = None + clist.save() + +def reverse(apps, schema_editor): + CommunityList = apps.get_model('community', 'CommunityList') + for clist in CommunityList.objects.all(): + try: + clist.user = clist.person.user + except: + clist.user = None + clist.save() + +class Migration(migrations.Migration): + dependencies = [ + ("community", "0004_delete_useless_community_lists"), + ("person", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="communitylist", + name="person", + field=ietf.utils.models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="person.Person", + ), + ), + migrations.RunPython(forward, reverse), + migrations.RemoveField( + model_name="communitylist", + name="user", + field=ietf.utils.models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/ietf/community/models.py b/ietf/community/models.py index 9b2383f211..2e40031768 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- -from django.contrib.auth.models import User from django.db import models from django.db.models import signals from django.urls import reverse as urlreverse @@ -13,13 +12,13 @@ from ietf.utils.models import ForeignKey class CommunityList(models.Model): - user = ForeignKey(User, blank=True, null=True) + person = ForeignKey(Person, blank=True, null=True) group = ForeignKey(Group, blank=True, null=True) added_docs = models.ManyToManyField(Document) def long_name(self): - if self.user: - return 'Personal I-D list of %s' % self.user.username + if self.person: + return 'Personal I-D list of %s' % self.person.plain_name() elif self.group: return 'I-D list for %s' % self.group.name else: @@ -30,8 +29,8 @@ def __str__(self): def get_absolute_url(self): import ietf.community.views - if self.user: - return urlreverse(ietf.community.views.view_list, kwargs={ 'username': self.user.username }) + if self.person: + return urlreverse(ietf.community.views.view_list, kwargs={ 'email_or_name': self.person.email() }) elif self.group: return urlreverse("ietf.group.views.group_documents", kwargs={ 'acronym': self.group.acronym }) return "" diff --git a/ietf/community/tests.py b/ietf/community/tests.py index ee5827ee25..387877887f 100644 --- a/ietf/community/tests.py +++ b/ietf/community/tests.py @@ -1,13 +1,10 @@ -# Copyright The IETF Trust 2016-2020, All Rights Reserved +# Copyright The IETF Trust 2016-2023, All Rights Reserved # -*- coding: utf-8 -*- from pyquery import PyQuery from django.urls import reverse as urlreverse -from django.contrib.auth.models import User - -from django_webtest import WebTest import debug # pyflakes:ignore @@ -19,14 +16,14 @@ from ietf.group.utils import setup_default_community_list_for_group from ietf.doc.models import State from ietf.doc.utils import add_state_change_event -from ietf.person.models import Person, Email -from ietf.utils.test_utils import login_testing_unauthorized +from ietf.person.models import Person, Email, Alias +from ietf.utils.test_utils import TestCase, login_testing_unauthorized from ietf.utils.mail import outbox from ietf.doc.factories import WgDraftFactory from ietf.group.factories import GroupFactory, RoleFactory -from ietf.person.factories import PersonFactory +from ietf.person.factories import PersonFactory, EmailFactory, AliasFactory -class CommunityListTests(WebTest): +class CommunityListTests(TestCase): def test_rule_matching(self): plain = PersonFactory(user__username='plain') ad = Person.objects.get(user__username='ad') @@ -38,7 +35,7 @@ def test_rule_matching(self): states=[('draft-iesg','lc'),('draft','active')], ) - clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist = CommunityList.objects.create(person=plain) rule_group = SearchRule.objects.create(rule_type="group", group=draft.group, state=State.objects.get(type="draft", slug="active"), community_list=clist) rule_group_rfc = SearchRule.objects.create(rule_type="group_rfc", group=draft.group, state=State.objects.get(type="rfc", slug="published"), community_list=clist) @@ -89,18 +86,38 @@ def test_rule_matching(self): # rule -> docs self.assertTrue(draft in list(docs_matching_community_list_rule(rule_group_exp))) + def test_view_list_duplicates(self): + person = PersonFactory(name="John Q. Public", user__username="bazquux@example.com") + PersonFactory(name="John Q. Public", user__username="foobar@example.com") + + url = urlreverse(ietf.community.views.view_list, kwargs={ "email_or_name": person.plain_name()}) + r = self.client.get(url) + self.assertEqual(r.status_code, 300) + self.assertIn("bazquux@example.com", r.content.decode()) + self.assertIn("foobar@example.com", r.content.decode()) + + def complex_person(self, *args, **kwargs): + person = PersonFactory(*args, **kwargs) + EmailFactory(person=person) + AliasFactory(person=person) + return person + + def email_or_name_set(self, person): + return [e for e in Email.objects.filter(person=person)] + \ + [a for a in Alias.objects.filter(person=person)] + def test_view_list(self): - PersonFactory(user__username='plain') + person = self.complex_person(user__username='plain') draft = WgDraftFactory() - url = urlreverse(ietf.community.views.view_list, kwargs={ "username": "plain" }) - # without list - r = self.client.get(url) - self.assertEqual(r.status_code, 200) + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.view_list, kwargs={ "email_or_name": id }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") # with list - clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist = CommunityList.objects.create(person=person) if not draft in clist.added_docs.all(): clist.added_docs.add(draft) SearchRule.objects.create( @@ -109,80 +126,87 @@ def test_view_list(self): state=State.objects.get(type="draft", slug="active"), text="test", ) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertContains(r, draft.name) + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.view_list, kwargs={ "email_or_name": id }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + self.assertContains(r, draft.name) def test_manage_personal_list(self): - - PersonFactory(user__username='plain') + person = self.complex_person(user__username='plain') ad = Person.objects.get(user__username='ad') draft = WgDraftFactory(authors=[ad]) - url = urlreverse(ietf.community.views.manage_list, kwargs={ "username": "plain" }) + url = urlreverse(ietf.community.views.manage_list, kwargs={ "email_or_name": person.email() }) login_testing_unauthorized(self, "plain", url) - page = self.app.get(url, user='plain') - self.assertEqual(page.status_int, 200) - - # add document - self.assertIn('add_document', page.forms) - form = page.forms['add_document'] - form['documents'].options=[(draft.pk, True, draft.name)] - page = form.submit('action',value='add_documents') - self.assertEqual(page.status_int, 302) - clist = CommunityList.objects.get(user__username="plain") - self.assertTrue(clist.added_docs.filter(pk=draft.pk)) - page = page.follow() - - self.assertContains(page, draft.name) - - # remove document - self.assertIn('remove_document_%s' % draft.pk, page.forms) - form = page.forms['remove_document_%s' % draft.pk] - page = form.submit('action',value='remove_document') - self.assertEqual(page.status_int, 302) - clist = CommunityList.objects.get(user__username="plain") - self.assertTrue(not clist.added_docs.filter(pk=draft.pk)) - page = page.follow() - - # add rule - r = self.client.post(url, { - "action": "add_rule", - "rule_type": "author_rfc", - "author_rfc-person": Person.objects.filter(documentauthor__document=draft).first().pk, + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.manage_list, kwargs={ "email_or_name": id }) + r = self.client.get(url, user='plain') + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + + # We can't call post() with follow=True because that 404's if + # the url contains unicode, because the django test client + # apparently re-encodes the already-encoded url. + def follow(r): + redirect_url = r.url or url + return self.client.get(redirect_url, user='plain') + + # add document + self.assertContains(r, 'add_document') + r = self.client.post(url, {'action': 'add_documents', 'documents': draft.pk}) + self.assertEqual(r.status_code, 302, msg=f"id='{id}', url='{url}'") + clist = CommunityList.objects.get(person__user__username="plain") + self.assertTrue(clist.added_docs.filter(pk=draft.pk)) + r = follow(r) + self.assertContains(r, draft.name, status_code=200) + + # remove document + self.assertContains(r, 'remove_document_%s' % draft.pk) + r = self.client.post(url, {'action': 'remove_document', 'document': draft.pk}) + self.assertEqual(r.status_code, 302, msg=f"id='{id}', url='{url}'") + clist = CommunityList.objects.get(person__user__username="plain") + self.assertTrue(not clist.added_docs.filter(pk=draft.pk)) + r = follow(r) + self.assertNotContains(r, draft.name, status_code=200) + + # add rule + r = self.client.post(url, { + "action": "add_rule", + "rule_type": "author_rfc", + "author_rfc-person": Person.objects.filter(documentauthor__document=draft).first().pk, "author_rfc-state": State.objects.get(type="rfc", slug="published").pk, - }) - self.assertEqual(r.status_code, 302) - clist = CommunityList.objects.get(user__username="plain") - self.assertTrue(clist.searchrule_set.filter(rule_type="author_rfc")) - - # add name_contains rule - r = self.client.post(url, { - "action": "add_rule", - "rule_type": "name_contains", - "name_contains-text": "draft.*mars", - "name_contains-state": State.objects.get(type="draft", slug="active").pk, - }) - self.assertEqual(r.status_code, 302) - clist = CommunityList.objects.get(user__username="plain") - self.assertTrue(clist.searchrule_set.filter(rule_type="name_contains")) - - # rule shows up on GET - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - rule = clist.searchrule_set.filter(rule_type="author_rfc").first() - q = PyQuery(r.content) - self.assertEqual(len(q('#r%s' % rule.pk)), 1) + }) + self.assertEqual(r.status_code, 302, msg=f"id='{id}', url='{url}'") + clist = CommunityList.objects.get(person__user__username="plain") + self.assertTrue(clist.searchrule_set.filter(rule_type="author_rfc")) + + # add name_contains rule + r = self.client.post(url, { + "action": "add_rule", + "rule_type": "name_contains", + "name_contains-text": "draft.*mars", + "name_contains-state": State.objects.get(type="draft", slug="active").pk, + }) + self.assertEqual(r.status_code, 302, msg=f"id='{id}', url='{url}'") + clist = CommunityList.objects.get(person__user__username="plain") + self.assertTrue(clist.searchrule_set.filter(rule_type="name_contains")) + + # rule shows up on GET + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + rule = clist.searchrule_set.filter(rule_type="author_rfc").first() + q = PyQuery(r.content) + self.assertEqual(len(q('#r%s' % rule.pk)), 1) - # remove rule - r = self.client.post(url, { - "action": "remove_rule", - "rule": rule.pk, - }) + # remove rule + r = self.client.post(url, { + "action": "remove_rule", + "rule": rule.pk, + }) - clist = CommunityList.objects.get(user__username="plain") - self.assertTrue(not clist.searchrule_set.filter(rule_type="author_rfc")) + clist = CommunityList.objects.get(person__user__username="plain") + self.assertTrue(not clist.searchrule_set.filter(rule_type="author_rfc")) def test_manage_group_list(self): draft = WgDraftFactory(group__acronym='mars') @@ -210,77 +234,84 @@ def test_manage_group_list(self): self.assertEqual(r.status_code, 200) def test_track_untrack_document(self): - PersonFactory(user__username='plain') + person = self.complex_person(user__username='plain') draft = WgDraftFactory() - url = urlreverse(ietf.community.views.track_document, kwargs={ "username": "plain", "name": draft.name }) + url = urlreverse(ietf.community.views.track_document, kwargs={ "email_or_name": person.email(), "name": draft.name }) login_testing_unauthorized(self, "plain", url) - # track - r = self.client.get(url) - self.assertEqual(r.status_code, 200) + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.track_document, kwargs={ "email_or_name": id, "name": draft.name }) - r = self.client.post(url) - self.assertEqual(r.status_code, 302) - clist = CommunityList.objects.get(user__username="plain") - self.assertEqual(list(clist.added_docs.all()), [draft]) + # track + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") - # untrack - url = urlreverse(ietf.community.views.untrack_document, kwargs={ "username": "plain", "name": draft.name }) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) + r = self.client.post(url) + self.assertEqual(r.status_code, 302, msg=f"id='{id}', url='{url}'") + clist = CommunityList.objects.get(person__user__username="plain") + self.assertEqual(list(clist.added_docs.all()), [draft]) + + # untrack + url = urlreverse(ietf.community.views.untrack_document, kwargs={ "email_or_name": id, "name": draft.name }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") - r = self.client.post(url) - self.assertEqual(r.status_code, 302) - clist = CommunityList.objects.get(user__username="plain") - self.assertEqual(list(clist.added_docs.all()), []) + r = self.client.post(url) + self.assertEqual(r.status_code, 302, msg=f"id='{id}', url='{url}'") + clist = CommunityList.objects.get(person__user__username="plain") + self.assertEqual(list(clist.added_docs.all()), []) def test_track_untrack_document_through_ajax(self): - PersonFactory(user__username='plain') + person = self.complex_person(user__username='plain') draft = WgDraftFactory() - url = urlreverse(ietf.community.views.track_document, kwargs={ "username": "plain", "name": draft.name }) + url = urlreverse(ietf.community.views.track_document, kwargs={ "email_or_name": person.email(), "name": draft.name }) login_testing_unauthorized(self, "plain", url) - # track - r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(r.status_code, 200) - self.assertEqual(r.json()["success"], True) - clist = CommunityList.objects.get(user__username="plain") - self.assertEqual(list(clist.added_docs.all()), [draft]) + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.track_document, kwargs={ "email_or_name": id, "name": draft.name }) - # untrack - url = urlreverse(ietf.community.views.untrack_document, kwargs={ "username": "plain", "name": draft.name }) - r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(r.status_code, 200) - self.assertEqual(r.json()["success"], True) - clist = CommunityList.objects.get(user__username="plain") - self.assertEqual(list(clist.added_docs.all()), []) + # track + r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + self.assertEqual(r.json()["success"], True) + clist = CommunityList.objects.get(person__user__username="plain") + self.assertEqual(list(clist.added_docs.all()), [draft]) + + # untrack + url = urlreverse(ietf.community.views.untrack_document, kwargs={ "email_or_name": id, "name": draft.name }) + r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + self.assertEqual(r.json()["success"], True) + clist = CommunityList.objects.get(person__user__username="plain") + self.assertEqual(list(clist.added_docs.all()), []) def test_csv(self): - PersonFactory(user__username='plain') + person = self.complex_person(user__username='plain') draft = WgDraftFactory() - url = urlreverse(ietf.community.views.export_to_csv, kwargs={ "username": "plain" }) + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.export_to_csv, kwargs={ "email_or_name": id }) - # without list - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - - # with list - clist = CommunityList.objects.create(user=User.objects.get(username="plain")) - if not draft in clist.added_docs.all(): - clist.added_docs.add(draft) - SearchRule.objects.create( - community_list=clist, - rule_type="name_contains", - state=State.objects.get(type="draft", slug="active"), - text="test", - ) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - # this is a simple-minded test, we don't actually check the fields - self.assertContains(r, draft.name) + # without list + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + + # with list + clist = CommunityList.objects.create(person=person) + if not draft in clist.added_docs.all(): + clist.added_docs.add(draft) + SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + state=State.objects.get(type="draft", slug="active"), + text="test", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + # this is a simple-minded test, we don't actually check the fields + self.assertContains(r, draft.name) def test_csv_for_group(self): draft = WgDraftFactory() @@ -294,33 +325,34 @@ def test_csv_for_group(self): self.assertEqual(r.status_code, 200) def test_feed(self): - PersonFactory(user__username='plain') + person = self.complex_person(user__username='plain') draft = WgDraftFactory() - url = urlreverse(ietf.community.views.feed, kwargs={ "username": "plain" }) + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.feed, kwargs={ "email_or_name": id }) - # without list - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - - # with list - clist = CommunityList.objects.create(user=User.objects.get(username="plain")) - if not draft in clist.added_docs.all(): - clist.added_docs.add(draft) - SearchRule.objects.create( - community_list=clist, - rule_type="name_contains", - state=State.objects.get(type="draft", slug="active"), - text="test", - ) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertContains(r, draft.name) + # without list + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + + # with list + clist = CommunityList.objects.create(person=person) + if not draft in clist.added_docs.all(): + clist.added_docs.add(draft) + SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + state=State.objects.get(type="draft", slug="active"), + text="test", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + self.assertContains(r, draft.name) - # only significant - r = self.client.get(url + "?significant=1") - self.assertEqual(r.status_code, 200) - self.assertNotContains(r, '') + # only significant + r = self.client.get(url + "?significant=1") + self.assertEqual(r.status_code, 200, msg=f"id='{id}', url='{url}'") + self.assertNotContains(r, '') def test_feed_for_group(self): draft = WgDraftFactory() @@ -334,19 +366,21 @@ def test_feed_for_group(self): self.assertEqual(r.status_code, 200) def test_subscription(self): - PersonFactory(user__username='plain') + person = self.complex_person(user__username='plain') draft = WgDraftFactory() - url = urlreverse(ietf.community.views.subscription, kwargs={ "username": "plain" }) - + url = urlreverse(ietf.community.views.subscription, kwargs={ "email_or_name": person.email() }) login_testing_unauthorized(self, "plain", url) - # subscription without list - r = self.client.get(url) - self.assertEqual(r.status_code, 404) + for id in self.email_or_name_set(person): + url = urlreverse(ietf.community.views.subscription, kwargs={ "email_or_name": id }) + + # subscription without list + r = self.client.get(url) + self.assertEqual(r.status_code, 404, msg=f"id='{id}', url='{url}'") # subscription with list - clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist = CommunityList.objects.create(person=person) if not draft in clist.added_docs.all(): clist.added_docs.add(draft) SearchRule.objects.create( @@ -355,22 +389,25 @@ def test_subscription(self): state=State.objects.get(type="draft", slug="active"), text="test", ) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - # subscribe - email = Email.objects.filter(person__user__username="plain").first() - r = self.client.post(url, { "email": email.pk, "notify_on": "significant", "action": "subscribe" }) - self.assertEqual(r.status_code, 302) + for email in Email.objects.filter(person=person): + url = urlreverse(ietf.community.views.subscription, kwargs={ "email_or_name": email }) - subscription = EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").first() + r = self.client.get(url) + self.assertEqual(r.status_code, 200) - self.assertTrue(subscription) + # subscribe + r = self.client.post(url, { "email": email.pk, "notify_on": "significant", "action": "subscribe" }) + self.assertEqual(r.status_code, 302) - # delete subscription - r = self.client.post(url, { "subscription_id": subscription.pk, "action": "unsubscribe" }) - self.assertEqual(r.status_code, 302) - self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").count(), 0) + subscription = EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").first() + + self.assertTrue(subscription) + + # delete subscription + r = self.client.post(url, { "subscription_id": subscription.pk, "action": "unsubscribe" }) + self.assertEqual(r.status_code, 302) + self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").count(), 0) def test_subscription_for_group(self): draft = WgDraftFactory(group__acronym='mars') @@ -385,12 +422,12 @@ def test_subscription_for_group(self): # test GET, rest is tested with personal list r = self.client.get(url) self.assertEqual(r.status_code, 200) - + def test_notification(self): - PersonFactory(user__username='plain') + person = PersonFactory(user__username='plain') draft = WgDraftFactory() - clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist = CommunityList.objects.create(person=person) if not draft in clist.added_docs.all(): clist.added_docs.add(draft) diff --git a/ietf/community/urls.py b/ietf/community/urls.py index f80547ffad..3ab132f2dc 100644 --- a/ietf/community/urls.py +++ b/ietf/community/urls.py @@ -4,11 +4,11 @@ from ietf.utils.urls import url urlpatterns = [ - url(r'^personal/(?P[^/]+)/$', views.view_list), - url(r'^personal/(?P[^/]+)/manage/$', views.manage_list), - url(r'^personal/(?P[^/]+)/trackdocument/(?P[^/]+)/$', views.track_document), - url(r'^personal/(?P[^/]+)/untrackdocument/(?P[^/]+)/$', views.untrack_document), - url(r'^personal/(?P[^/]+)/csv/$', views.export_to_csv), - url(r'^personal/(?P[^/]+)/feed/$', views.feed), - url(r'^personal/(?P[^/]+)/subscription/$', views.subscription), + url(r'^personal/(?P[^/]+)/$', views.view_list), + url(r'^personal/(?P[^/]+)/manage/$', views.manage_list), + url(r'^personal/(?P[^/]+)/trackdocument/(?P[^/]+)/$', views.track_document), + url(r'^personal/(?P[^/]+)/untrackdocument/(?P[^/]+)/$', views.untrack_document), + url(r'^personal/(?P[^/]+)/csv/$', views.export_to_csv), + url(r'^personal/(?P[^/]+)/feed/$', views.feed), + url(r'^personal/(?P[^/]+)/subscription/$', views.subscription), ] diff --git a/ietf/community/utils.py b/ietf/community/utils.py index f411af6a5f..f23e8d26ab 100644 --- a/ietf/community/utils.py +++ b/ietf/community/utils.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2016-2020, All Rights Reserved +# Copyright The IETF Trust 2016-2023, All Rights Reserved # -*- coding: utf-8 -*- @@ -11,11 +11,9 @@ from ietf.community.models import CommunityList, EmailSubscription, SearchRule from ietf.doc.models import Document, State -from ietf.group.models import Role, Group +from ietf.group.models import Role from ietf.person.models import Person from ietf.ietfauth.utils import has_role -from django.contrib.auth.models import User -from django.shortcuts import get_object_or_404 from ietf.utils.mail import send_mail @@ -29,24 +27,12 @@ def states_of_significant_change(): Q(type="draft", slug__in=['rfc', 'dead']) ) -def lookup_community_list(username=None, acronym=None): - assert username or acronym - - if acronym: - group = get_object_or_404(Group, acronym=acronym) - clist = CommunityList.objects.filter(group=group).first() or CommunityList(group=group) - else: - user = get_object_or_404(User, username__iexact=username) - clist = CommunityList.objects.filter(user=user).first() or CommunityList(user=user) - - return clist - def can_manage_community_list(user, clist): if not user or not user.is_authenticated: return False - if clist.user: - return user == clist.user + if clist.person: + return user == clist.person.user elif clist.group: if has_role(user, 'Secretariat'): return True diff --git a/ietf/community/views.py b/ietf/community/views.py index fdaaffec0b..4a28a391f0 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2012-2020, All Rights Reserved +# Copyright The IETF Trust 2012-2023, All Rights Reserved # -*- coding: utf-8 -*- @@ -15,18 +15,47 @@ import debug # pyflakes:ignore -from ietf.community.models import SearchRule, EmailSubscription +from ietf.community.models import CommunityList, EmailSubscription, SearchRule from ietf.community.forms import SearchRuleTypeForm, SearchRuleForm, AddDocumentsForm, SubscriptionForm -from ietf.community.utils import lookup_community_list, can_manage_community_list +from ietf.community.utils import can_manage_community_list from ietf.community.utils import docs_tracked_by_community_list, docs_matching_community_list_rule from ietf.community.utils import states_of_significant_change, reset_name_contains_index_for_rule +from ietf.group.models import Group from ietf.doc.models import DocEvent, Document from ietf.doc.utils_search import prepare_document_table +from ietf.person.utils import lookup_persons +from ietf.utils.decorators import ignore_view_kwargs from ietf.utils.http import is_ajax from ietf.utils.response import permission_denied -def view_list(request, username=None): - clist = lookup_community_list(username) +class MultiplePersonError(Exception): + """More than one Person record matches the given email or name""" + pass + +def lookup_community_list(request, email_or_name=None, acronym=None): + assert email_or_name or acronym + + if acronym: + group = get_object_or_404(Group, acronym=acronym) + clist = CommunityList.objects.filter(group=group).first() or CommunityList(group=group) + else: + persons = lookup_persons(email_or_name) + if len(persons) > 1: + if hasattr(request.user, 'person') and request.user.person in persons: + person = request.user.person + else: + raise MultiplePersonError("\r\n".join([p.user.username for p in persons])) + else: + person = persons[0] + clist = CommunityList.objects.filter(person=person).first() or CommunityList(person=person) + + return clist + +def view_list(request, email_or_name=None): + try: + clist = lookup_community_list(request, email_or_name) + except MultiplePersonError as err: + return HttpResponse(str(err), status=300) docs = docs_tracked_by_community_list(clist) docs, meta = prepare_document_table(request, docs, request.GET) @@ -42,10 +71,14 @@ def view_list(request, username=None): }) @login_required -def manage_list(request, username=None, acronym=None, group_type=None): +@ignore_view_kwargs("group_type") +def manage_list(request, email_or_name=None, acronym=None): # we need to be a bit careful because clist may not exist in the # database so we can't call related stuff on it yet - clist = lookup_community_list(username, acronym) + try: + clist = lookup_community_list(request, email_or_name, acronym) + except MultiplePersonError as err: + return HttpResponse(str(err), status=300) if not can_manage_community_list(request.user, clist): permission_denied(request, "You do not have permission to access this view") @@ -128,11 +161,14 @@ def manage_list(request, username=None, acronym=None, group_type=None): @login_required -def track_document(request, name, username=None, acronym=None): +def track_document(request, name, email_or_name=None, acronym=None): doc = get_object_or_404(Document, name=name) if request.method == "POST": - clist = lookup_community_list(username, acronym) + try: + clist = lookup_community_list(request, email_or_name, acronym) + except MultiplePersonError as err: + return HttpResponse(str(err), status=300) if not can_manage_community_list(request.user, clist): permission_denied(request, "You do not have permission to access this view") @@ -152,9 +188,12 @@ def track_document(request, name, username=None, acronym=None): }) @login_required -def untrack_document(request, name, username=None, acronym=None): +def untrack_document(request, name, email_or_name=None, acronym=None): doc = get_object_or_404(Document, name=name) - clist = lookup_community_list(username, acronym) + try: + clist = lookup_community_list(request, email_or_name, acronym) + except MultiplePersonError as err: + return HttpResponse(str(err), status=300) if not can_manage_community_list(request.user, clist): permission_denied(request, "You do not have permission to access this view") @@ -172,8 +211,12 @@ def untrack_document(request, name, username=None, acronym=None): }) -def export_to_csv(request, username=None, acronym=None, group_type=None): - clist = lookup_community_list(username, acronym) +@ignore_view_kwargs("group_type") +def export_to_csv(request, email_or_name=None, acronym=None): + try: + clist = lookup_community_list(request, email_or_name, acronym) + except MultiplePersonError as err: + return HttpResponse(str(err), status=300) response = HttpResponse(content_type='text/csv') @@ -213,8 +256,12 @@ def export_to_csv(request, username=None, acronym=None, group_type=None): return response -def feed(request, username=None, acronym=None, group_type=None): - clist = lookup_community_list(username, acronym) +@ignore_view_kwargs("group_type") +def feed(request, email_or_name=None, acronym=None): + try: + clist = lookup_community_list(request, email_or_name, acronym) + except MultiplePersonError as err: + return HttpResponse(str(err), status=300) significant = request.GET.get('significant', '') == '1' @@ -249,17 +296,23 @@ def feed(request, username=None, acronym=None, group_type=None): @login_required -def subscription(request, username=None, acronym=None, group_type=None): - clist = lookup_community_list(username, acronym) - if clist.pk is None: - raise Http404 +@ignore_view_kwargs("group_type") +def subscription(request, email_or_name=None, acronym=None): + try: + clist = lookup_community_list(request, email_or_name, acronym) + if clist.pk is None: + raise Http404 + except MultiplePersonError as err: + return HttpResponse(str(err), status=300) + + person = request.user.person - existing_subscriptions = EmailSubscription.objects.filter(community_list=clist, email__person__user=request.user) + existing_subscriptions = EmailSubscription.objects.filter(community_list=clist, email__person=person) if request.method == 'POST': action = request.POST.get("action") if action == "subscribe": - form = SubscriptionForm(request.user, clist, request.POST) + form = SubscriptionForm(person, clist, request.POST) if form.is_valid(): subscription = form.save(commit=False) subscription.community_list = clist @@ -272,7 +325,7 @@ def subscription(request, username=None, acronym=None, group_type=None): return HttpResponseRedirect("") else: - form = SubscriptionForm(request.user, clist) + form = SubscriptionForm(person, clist) return render(request, 'community/subscription.html', { 'clist': clist, diff --git a/ietf/doc/management/commands/generate_draft_aliases.py b/ietf/doc/management/commands/generate_draft_aliases.py deleted file mode 100755 index 6d42a66a18..0000000000 --- a/ietf/doc/management/commands/generate_draft_aliases.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright The IETF Trust 2012-2021, All Rights Reserved -# -*- coding: utf-8 -*- - -# This was written as a script by Markus Stenberg . -# It was turned into a management command by Russ Housley . - -import datetime -import io -import os -import re -import shutil -import stat -import time - -from tempfile import mkstemp - -from django.conf import settings -from django.core.management.base import BaseCommand -from django.utils import timezone - -import debug # pyflakes:ignore - -from ietf.doc.models import Document -from ietf.group.utils import get_group_role_emails, get_group_ad_emails -from ietf.utils.aliases import dump_sublist -from utils.mail import parseaddr -from ietf.utils import log - -DEFAULT_YEARS = 2 - - -def get_draft_ad_emails(doc): - """Get AD email addresses for the given draft, if any.""" - 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(doc): - """Get chair email addresses for the given draft, if any.""" - 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(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(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(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(get_draft_ad_emails(doc)) - elif re.search(author_email_alias_regex, e): - notify_emails.update(get_draft_authors_emails(doc)) - elif re.search(shepherd_email_alias_regex, e): - notify_emails.update(get_draft_shepherd_email(doc)) - elif re.search(all_email_alias_regex, e): - notify_emails.update(get_draft_ad_emails(doc)) - notify_emails.update(get_draft_authors_emails(doc)) - notify_emails.update(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 - - -class Command(BaseCommand): - help = ('Generate the draft-aliases and draft-virtual files for Internet-Draft ' - 'mail aliases, placing them in the files configured in ' - 'settings.DRAFT_ALIASES_PATH and settings.DRAFT_VIRTUAL_PATH, ' - 'respectively. The generation includes aliases for Internet-Drafts ' - 'that have seen activity in the last %s years.' % (DEFAULT_YEARS)) - - def handle(self, *args, **options): - show_since = timezone.now() - datetime.timedelta(DEFAULT_YEARS*365) - - date = time.strftime("%Y-%m-%d_%H:%M:%S") - signature = '# Generated by %s at %s\n' % (os.path.abspath(__file__), date) - - ahandle, aname = mkstemp() - os.close(ahandle) - afile = io.open(aname,"w") - - vhandle, vname = mkstemp() - os.close(vhandle) - vfile = io.open(vname,"w") - - afile.write(signature) - vfile.write(signature) - vfile.write("%s anything\n" % settings.DRAFT_VIRTUAL_DOMAIN) - - # Internet-Drafts with active status or expired within DEFAULT_YEARS - 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 - - alias_domains = ['ietf.org', ] - for draft in interesting_drafts.distinct().iterator(): - # Omit drafts that became RFCs, unless they were published in the last DEFAULT_YEARS - if draft.get_state_slug()=="rfc": - rfc = draft.became_rfc() - log.assertion("rfc is not None") - if rfc.latest_event(type='published_rfc').time < show_since: - continue - - alias = draft.name - all = set() - - # no suffix and .authors are the same list - emails = get_draft_authors_emails(draft) - all.update(emails) - dump_sublist(afile, vfile, alias, alias_domains, settings.DRAFT_VIRTUAL_DOMAIN, emails) - dump_sublist(afile, vfile, alias+'.authors', alias_domains, settings.DRAFT_VIRTUAL_DOMAIN, emails) - - # .chairs = group chairs - emails = get_draft_chair_emails(draft) - if emails: - all.update(emails) - dump_sublist(afile, vfile, alias+'.chairs', alias_domains, settings.DRAFT_VIRTUAL_DOMAIN, emails) - - # .ad = sponsoring AD / WG AD (WG document) - emails = get_draft_ad_emails(draft) - if emails: - all.update(emails) - dump_sublist(afile, vfile, alias+'.ad', alias_domains, settings.DRAFT_VIRTUAL_DOMAIN, emails) - - # .notify = notify email list from the Document - emails = get_draft_notify_emails(draft) - if emails: - all.update(emails) - dump_sublist(afile, vfile, alias+'.notify', alias_domains, settings.DRAFT_VIRTUAL_DOMAIN, emails) - - # .shepherd = shepherd email from the Document - emails = get_draft_shepherd_email(draft) - if emails: - all.update(emails) - dump_sublist(afile, vfile, alias+'.shepherd', alias_domains, settings.DRAFT_VIRTUAL_DOMAIN, emails) - - # .all = everything from above - dump_sublist(afile, vfile, alias+'.all', alias_domains, settings.DRAFT_VIRTUAL_DOMAIN, all) - - afile.close() - vfile.close() - - os.chmod(aname, stat.S_IWUSR|stat.S_IRUSR|stat.S_IRGRP|stat.S_IROTH) - os.chmod(vname, stat.S_IWUSR|stat.S_IRUSR|stat.S_IRGRP|stat.S_IROTH) - - shutil.move(aname, settings.DRAFT_ALIASES_PATH) - shutil.move(vname, settings.DRAFT_VIRTUAL_PATH) - - \ No newline at end of file diff --git a/ietf/doc/management/commands/reset_rfc_authors.py b/ietf/doc/management/commands/reset_rfc_authors.py new file mode 100644 index 0000000000..e2ab5f1208 --- /dev/null +++ b/ietf/doc/management/commands/reset_rfc_authors.py @@ -0,0 +1,69 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +# Reset an RFC's authors to those of the draft it came from +from django.core.management.base import BaseCommand, CommandError + +from ietf.doc.models import Document, DocEvent +from ietf.person.models import Person + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument("rfcnum", type=int, help="RFC number to modify") + parser.add_argument( + "--force", + action="store_true", + help="reset even if RFC already has authors", + ) + + def handle(self, *args, **options): + try: + rfc = Document.objects.get(type="rfc", rfc_number=options["rfcnum"]) + except Document.DoesNotExist: + raise CommandError( + f"rfc{options['rfcnum']} does not exist in the Datatracker." + ) + + draft = rfc.came_from_draft() + if draft is None: + raise CommandError(f"{rfc.name} did not come from a draft. Can't reset.") + + orig_authors = rfc.documentauthor_set.all() + if orig_authors.exists(): + # Potentially dangerous, so refuse unless "--force" is specified + if not options["force"]: + raise CommandError( + f"{rfc.name} already has authors. Not resetting. Use '--force' to reset anyway." + ) + removed_auth_names = list(orig_authors.values_list("person__name", flat=True)) + rfc.documentauthor_set.all().delete() + DocEvent.objects.create( + doc=rfc, + by=Person.objects.get(name="(System)"), + type="edited_authors", + desc=f"Removed all authors: {', '.join(removed_auth_names)}", + ) + self.stdout.write( + self.style.SUCCESS( + f"Removed author(s): {', '.join(removed_auth_names)}" + ) + ) + + for author in draft.documentauthor_set.all(): + # Copy the author but point at the new doc. + # See https://docs.djangoproject.com/en/4.2/topics/db/queries/#copying-model-instances + author.pk = None + author.id = None + author._state.adding = True + author.document = rfc + author.save() + self.stdout.write( + self.style.SUCCESS(f"Added author {author.person.name} <{author.email}>") + ) + auth_names = draft.documentauthor_set.values_list("person__name", flat=True) + DocEvent.objects.create( + doc=rfc, + by=Person.objects.get(name="(System)"), + type="edited_authors", + desc=f"Set authors from rev {draft.rev} of {draft.name}: {', '.join(auth_names)}", + ) diff --git a/ietf/doc/management/commands/tests.py b/ietf/doc/management/commands/tests.py new file mode 100644 index 0000000000..8244d87266 --- /dev/null +++ b/ietf/doc/management/commands/tests.py @@ -0,0 +1,72 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# -*- coding: utf-8 -*- + +from io import StringIO + +from django.core.management import call_command, CommandError + +from ietf.doc.factories import DocumentAuthorFactory, WgDraftFactory, WgRfcFactory +from ietf.doc.models import Document, DocumentAuthor +from ietf.utils.test_utils import TestCase + + +class CommandTests(TestCase): + @staticmethod + def _call_command(command_name, *args, **options): + """Call command, capturing (and suppressing) output""" + out = StringIO() + err = StringIO() + options["stdout"] = out + options["stderr"] = err + call_command(command_name, *args, **options) + return out.getvalue(), err.getvalue() + + def test_reset_rfc_authors(self): + command_name = "reset_rfc_authors" + + draft = WgDraftFactory() + DocumentAuthorFactory.create_batch(3, document=draft) + rfc = WgRfcFactory() # rfc does not yet have a draft + DocumentAuthorFactory.create_batch(3, document=rfc) + bad_rfc_num = ( + 1 + + Document.objects.filter(rfc_number__isnull=False) + .order_by("-rfc_number") + .first() + .rfc_number + ) + docauthor_fields = [ + field.name + for field in DocumentAuthor._meta.get_fields() + if field.name not in ["document", "id"] + ] + + with self.assertRaises(CommandError, msg="Cannot reset a bad RFC number"): + self._call_command(command_name, bad_rfc_num) + + with self.assertRaises(CommandError, msg="Cannot reset an RFC with no draft"): + self._call_command(command_name, rfc.rfc_number) + + with self.assertRaises(CommandError, msg="Cannot force-reset an RFC with no draft"): + self._call_command(command_name, rfc.rfc_number, "--force") + + # Link the draft to the rfc + rfc.targets_related.create(relationship_id="became_rfc", source=draft) + + with self.assertRaises(CommandError, msg="Cannot reset an RFC with authors"): + self._call_command(command_name, rfc.rfc_number) + + # Calling with force should work + self._call_command(command_name, rfc.rfc_number, "--force") + self.assertCountEqual( + draft.documentauthor_set.values(*docauthor_fields), + rfc.documentauthor_set.values(*docauthor_fields), + ) + + # Calling on an RFC with no authors should also work + rfc.documentauthor_set.all().delete() + self._call_command(command_name, rfc.rfc_number) + self.assertCountEqual( + draft.documentauthor_set.values(*docauthor_fields), + rfc.documentauthor_set.values(*docauthor_fields), + ) diff --git a/ietf/doc/migrations/0021_narrativeminutes.py b/ietf/doc/migrations/0021_narrativeminutes.py new file mode 100644 index 0000000000..0f330bd053 --- /dev/null +++ b/ietf/doc/migrations/0021_narrativeminutes.py @@ -0,0 +1,39 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.db import migrations + + +def forward(apps, schema_editor): + StateType = apps.get_model("doc", "StateType") + State = apps.get_model("doc", "State") + + StateType.objects.create( + slug="narrativeminutes", + label="State", + ) + for order, slug in enumerate(["active", "deleted"]): + State.objects.create( + slug=slug, + type_id="narrativeminutes", + name=slug.capitalize(), + order=order, + desc="", + used=True, + ) + + +def reverse(apps, schema_editor): + StateType = apps.get_model("doc", "StateType") + State = apps.get_model("doc", "State") + + State.objects.filter(type_id="narrativeminutes").delete() + StateType.objects.filter(slug="narrativeminutes").delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("doc", "0020_move_errata_tags"), + ("name", "0013_narrativeminutes"), + ] + + operations = [migrations.RunPython(forward, reverse)] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 9275b54101..d97e8238ec 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -83,7 +83,7 @@ class State(models.Model): desc = models.TextField(blank=True) order = models.IntegerField(default=0) - next_states = models.ManyToManyField('State', related_name="previous_states", blank=True) + next_states = models.ManyToManyField('doc.State', related_name="previous_states", blank=True) def __str__(self): return self.name @@ -148,7 +148,7 @@ def get_file_path(self): else: self._cached_file_path = settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR elif self.meeting_related() and self.type_id in ( - "agenda", "minutes", "slides", "bluesheets", "procmaterials", "chatlog", "polls" + "agenda", "minutes", "narrativeminutes", "slides", "bluesheets", "procmaterials", "chatlog", "polls" ): meeting = self.get_related_meeting() if meeting is not None: @@ -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 @@ -425,7 +438,7 @@ def has_rfc_editor_note(self): return e != None and (e.text != "") def meeting_related(self): - if self.type_id in ("agenda","minutes","bluesheets","slides","recording","procmaterials","chatlog","polls"): + if self.type_id in ("agenda","minutes", "narrativeminutes", "bluesheets","slides","recording","procmaterials","chatlog","polls"): return self.type_id != "slides" or self.get_state_slug('reuse_policy')=='single' return False @@ -634,6 +647,9 @@ def pdfized(self): ) except AssertionError: pdf = None + except Exception as e: + log.log('weasyprint failed:'+str(e)) + raise if pdf: cache.set(cache_key, pdf, settings.PDFIZER_CACHE_TIME) return pdf @@ -649,7 +665,7 @@ def referenced_by(self): source__states__slug="active", ) | models.Q(source__type__slug="rfc") - ) + ).distinct() def referenced_by_rfcs(self): """Get refs to this doc from RFCs""" @@ -1012,7 +1028,7 @@ def related_ipr(self): def future_presentations(self): """ returns related SessionPresentation objects for meetings that have not yet ended. This implementation allows for 2 week meetings """ - candidate_presentations = self.sessionpresentation_set.filter( + candidate_presentations = self.presentations.filter( session__meeting__date__gte=date_today() - datetime.timedelta(days=15) ) return sorted( @@ -1025,11 +1041,11 @@ def last_presented(self): """ returns related SessionPresentation objects for the most recent meeting in the past""" # Assumes no two meetings have the same start date - if the assumption is violated, one will be chosen arbitrarily today = date_today() - candidate_presentations = self.sessionpresentation_set.filter(session__meeting__date__lte=today) + candidate_presentations = self.presentations.filter(session__meeting__date__lte=today) candidate_meetings = set([p.session.meeting for p in candidate_presentations if p.session.meeting.end_date() dict: @override_settings(RFC_EDITOR_INFO_BASE_URL='https://www.rfc-editor.ietf.org/info/') def test_document_bibtex(self): + + for factory in [CharterFactory, BcpFactory, StatusChangeFactory, ConflictReviewFactory]: # Should be extended to all other doc types + doc = factory() + url = urlreverse("ietf.doc.views_doc.document_bibtex", kwargs=dict(name=doc.name)) + r = self.client.get(url) + self.assertEqual(r.status_code, 404) rfc = WgRfcFactory.create( time=datetime.datetime(2010, 10, 10, tzinfo=ZoneInfo(settings.TIME_ZONE)) ) @@ -2133,7 +2181,9 @@ def tearDown(self): os.unlink(self.doc_virtual_file.name) super().tearDown() - def testManagementCommand(self): + @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( @@ -2151,12 +2201,12 @@ def testManagementCommand(self): mars.role_set.create( name_id="chair", person=marschairman, email=marschairman.email() ) - doc1 = IndividualDraftFactory( - authors=[author1], shepherd=shepherd.email(), ad=ad - ) + 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", @@ -2168,9 +2218,7 @@ def testManagementCommand(self): ) rfc3 = WgRfcFactory() DocEventFactory.create(doc=rfc3, type="published_rfc", time=a_month_ago) - doc3.relateddocument_set.create( - relationship_id="became_rfc", target=rfc3 - ) + doc3.relateddocument_set.create(relationship_id="became_rfc", target=rfc3) doc4 = WgDraftFactory.create( authors=[author4, author5], ad=ad, @@ -2184,99 +2232,96 @@ def testManagementCommand(self): type="published_rfc", time=datetime.datetime(2010, 10, 10, tzinfo=RPC_TZINFO), ) - doc4.relateddocument_set.create( - relationship_id="became_rfc", target=rfc4 - ) + doc4.relateddocument_set.create(relationship_id="became_rfc", target=rfc4) doc5 = IndividualDraftFactory(authors=[author6]) - args = [] - kwargs = {} - out = io.StringIO() - call_command("generate_draft_aliases", *args, **kwargs, stdout=out, stderr=out) - self.assertFalse(out.getvalue()) - - with open(settings.DRAFT_ALIASES_PATH) as afile: - acontent = afile.read() - for x in [ - "xfilter-" + doc1.name, - "xfilter-" + doc1.name + ".ad", - "xfilter-" + doc1.name + ".authors", - "xfilter-" + doc1.name + ".shepherd", - "xfilter-" + doc1.name + ".all", - "xfilter-" + doc2.name, - "xfilter-" + doc2.name + ".ad", - "xfilter-" + doc2.name + ".authors", - "xfilter-" + doc2.name + ".chairs", - "xfilter-" + doc2.name + ".all", - "xfilter-" + doc3.name, - "xfilter-" + doc3.name + ".ad", - "xfilter-" + doc3.name + ".authors", - "xfilter-" + doc3.name + ".chairs", - "xfilter-" + doc5.name, - "xfilter-" + doc5.name + ".authors", - "xfilter-" + doc5.name + ".all", - ]: - self.assertIn(x, acontent) - - for x in [ - "xfilter-" + doc1.name + ".chairs", - "xfilter-" + doc2.name + ".shepherd", - "xfilter-" + doc3.name + ".shepherd", - "xfilter-" + doc4.name, - "xfilter-" + doc5.name + ".shepherd", - "xfilter-" + doc5.name + ".ad", - ]: - self.assertNotIn(x, acontent) - - with open(settings.DRAFT_VIRTUAL_PATH) as vfile: - vcontent = vfile.read() - for x in [ + 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(), - marschairman.email_address(), - author1.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(), - author6.email_address(), - ]: - self.assertIn(x, vcontent) - - for x in [ - author4.email_address(), - author5.email_address(), - ]: - self.assertNotIn(x, vcontent) - - for x in [ - "xfilter-" + doc1.name, - "xfilter-" + doc1.name + ".ad", - "xfilter-" + doc1.name + ".authors", - "xfilter-" + doc1.name + ".shepherd", - "xfilter-" + doc1.name + ".all", - "xfilter-" + doc2.name, - "xfilter-" + doc2.name + ".ad", - "xfilter-" + doc2.name + ".authors", - "xfilter-" + doc2.name + ".chairs", - "xfilter-" + doc2.name + ".all", - "xfilter-" + doc3.name, - "xfilter-" + doc3.name + ".ad", - "xfilter-" + doc3.name + ".authors", - "xfilter-" + doc3.name + ".chairs", - "xfilter-" + doc5.name, - "xfilter-" + doc5.name + ".authors", - "xfilter-" + doc5.name + ".all", - ]: - self.assertIn(x, vcontent) - - for x in [ - "xfilter-" + doc1.name + ".chairs", - "xfilter-" + doc2.name + ".shepherd", - "xfilter-" + doc3.name + ".shepherd", - "xfilter-" + doc4.name, - "xfilter-" + doc5.name + ".shepherd", - "xfilter-" + doc5.name + ".ad", - ]: - self.assertNotIn(x, vcontent) + 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): @@ -2357,8 +2402,8 @@ def setUp(self): def test_view_document_meetings(self): doc = IndividualDraftFactory.create() - doc.sessionpresentation_set.create(session=self.inprog,rev=None) - doc.sessionpresentation_set.create(session=self.interim,rev=None) + doc.presentations.create(session=self.inprog,rev=None) + doc.presentations.create(session=self.interim,rev=None) url = urlreverse('ietf.doc.views_doc.all_presentations', kwargs=dict(name=doc.name)) response = self.client.get(url) @@ -2369,8 +2414,8 @@ def test_view_document_meetings(self): self.assertFalse(q('#addsessionsbutton')) self.assertFalse(q("a.btn:contains('Remove document')")) - doc.sessionpresentation_set.create(session=self.past_cutoff,rev=None) - doc.sessionpresentation_set.create(session=self.past,rev=None) + doc.presentations.create(session=self.past_cutoff,rev=None) + doc.presentations.create(session=self.past,rev=None) self.client.login(username="secretary", password="secretary+password") response = self.client.get(url) @@ -2403,41 +2448,72 @@ def test_view_document_meetings(self): self.assertFalse(q("#futuremeets a.btn:contains('Remove document')")) self.assertFalse(q("#pastmeets a.btn:contains('Remove document')")) - def test_edit_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_edit_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() - sp = doc.sessionpresentation_set.create(session=self.future,rev=None) + sp = doc.presentations.create(session=self.future,rev=None) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name='no-such-doc',session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name=doc.name,session_id=0)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=self.other_chair.user.username,password='%s+password'%self.other_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 404) - + self.assertFalse(mock_slides_manager_cls.called) + self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 200) q = PyQuery(response.content) self.assertEqual(2,len(q('select#id_version option'))) + self.assertFalse(mock_slides_manager_cls.called) + # edit draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'version':'00','save':''}) self.assertEqual(response.status_code, 302) - self.assertEqual(doc.sessionpresentation_set.get(pk=sp.pk).rev,'00') + self.assertEqual(doc.presentations.get(pk=sp.pk).rev,'00') self.assertEqual(2,doc.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + + # editing slides should call Meetecho API + slides = SessionPresentationFactory( + session=self.future, + document__type_id="slides", + document__rev="00", + rev=None, + order=1, + ).document + url = urlreverse( + "ietf.doc.views_doc.edit_sessionpresentation", + kwargs={"name": slides.name, "session_id": self.future.pk}, + ) + response = self.client.post(url, {"version": "00", "save": ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + mock.call(self.future), + ) def test_edit_document_session_after_proceedings_closed(self): doc = IndividualDraftFactory.create() - sp = doc.sessionpresentation_set.create(session=self.past_cutoff,rev=None) + sp = doc.presentations.create(session=self.past_cutoff,rev=None) url = urlreverse('ietf.doc.views_doc.edit_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) @@ -2450,39 +2526,64 @@ def test_edit_document_session_after_proceedings_closed(self): q=PyQuery(response.content) self.assertEqual(1,len(q(".alert-warning:contains('may affect published proceedings')"))) - def test_remove_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_remove_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() - sp = doc.sessionpresentation_set.create(session=self.future,rev=None) + sp = doc.presentations.create(session=self.future,rev=None) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name='no-such-doc',session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name=doc.name,session_id=0)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=self.other_chair.user.username,password='%s+password'%self.other_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 404) - + self.assertFalse(mock_slides_manager_cls.called) + self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) + # removing a draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'remove_session':''}) self.assertEqual(response.status_code, 302) - self.assertFalse(doc.sessionpresentation_set.filter(pk=sp.pk).exists()) + self.assertFalse(doc.presentations.filter(pk=sp.pk).exists()) self.assertEqual(2,doc.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + + # removing slides should call Meetecho API + slides = SessionPresentationFactory(session=self.future, document__type_id="slides", order=1).document + url = urlreverse( + "ietf.doc.views_doc.remove_sessionpresentation", + kwargs={"name": slides.name, "session_id": self.future.pk}, + ) + response = self.client.post(url, {"remove_session": ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.delete.call_args, + mock.call(self.future, slides), + ) def test_remove_document_session_after_proceedings_closed(self): doc = IndividualDraftFactory.create() - sp = doc.sessionpresentation_set.create(session=self.past_cutoff,rev=None) + sp = doc.presentations.create(session=self.past_cutoff,rev=None) url = urlreverse('ietf.doc.views_doc.remove_sessionpresentation',kwargs=dict(name=doc.name,session_id=sp.session_id)) self.client.login(username=self.group_chair.user.username,password='%s+password'%self.group_chair.user.username) @@ -2495,28 +2596,49 @@ def test_remove_document_session_after_proceedings_closed(self): q=PyQuery(response.content) self.assertEqual(1,len(q(".alert-warning:contains('may affect published proceedings')"))) - def test_add_document_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @mock.patch("ietf.doc.views_doc.SlidesManager") + def test_add_document_session(self, mock_slides_manager_cls): doc = IndividualDraftFactory.create() url = urlreverse('ietf.doc.views_doc.add_sessionpresentation',kwargs=dict(name=doc.name)) login_testing_unauthorized(self,self.group_chair.user.username,url) response = self.client.get(url) self.assertEqual(response.status_code,200) - + self.assertFalse(mock_slides_manager_cls.called) + response = self.client.post(url,{'session':0,'version':'current'}) self.assertEqual(response.status_code,200) q=PyQuery(response.content) self.assertTrue(q('.form-select.is-invalid')) + self.assertFalse(mock_slides_manager_cls.called) response = self.client.post(url,{'session':self.future.pk,'version':'bogus version'}) self.assertEqual(response.status_code,200) q=PyQuery(response.content) self.assertTrue(q('.form-select.is-invalid')) + self.assertFalse(mock_slides_manager_cls.called) + # adding a draft self.assertEqual(1,doc.docevent_set.count()) response = self.client.post(url,{'session':self.future.pk,'version':'current'}) self.assertEqual(response.status_code,302) self.assertEqual(2,doc.docevent_set.count()) + self.assertEqual(doc.presentations.get(session__pk=self.future.pk).order, 0) + self.assertFalse(mock_slides_manager_cls.called) + + # adding slides should set order / call Meetecho API + slides = DocumentFactory(type_id="slides") + url = urlreverse("ietf.doc.views_doc.add_sessionpresentation", kwargs=dict(name=slides.name)) + response = self.client.post(url, {"session": self.future.pk, "version": "current"}) + self.assertEqual(response.status_code,302) + self.assertEqual(slides.presentations.get(session__pk=self.future.pk).order, 1) + self.assertEqual(mock_slides_manager_cls.call_args, mock.call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.add.call_args, + mock.call(self.future, slides, order=1), + ) def test_get_related_meeting(self): """Should be able to retrieve related meeting""" @@ -2844,6 +2966,12 @@ def test_pdfized(self): self.should_succeed(dict(name=draft.name,rev=f'{r:02d}',ext=ext)) self.should_404(dict(name=draft.name,rev='02')) + with mock.patch('ietf.doc.models.DocumentInfo.pdfized', side_effect=URLFetchingError): + url = urlreverse(self.view, kwargs=dict(name=rfc.name)) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, "Error while rendering PDF") + class NotifyValidationTests(TestCase): def test_notify_validation(self): valid_values = [ @@ -2999,3 +3127,17 @@ def test_referenced_by_rfcs_as_rfc_or_draft(self): rfc.referenced_by_rfcs_as_rfc_or_draft(), draft.targets_related.filter(source__type="rfc") | rfc.targets_related.filter(source__type="rfc"), ) + +class StateIndexTests(TestCase): + + def test_state_index(self): + url = urlreverse('ietf.doc.views_help.state_index') + r = self.client.get(url) + q = PyQuery(r.content) + content = [ e.text for e in q('#content table td a ') ] + names = StateType.objects.values_list('slug', flat=True) + # The following doesn't cover all doc types, only a selection + for name in names: + if not '-' in name: + self.assertIn(name, content) + diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index e168f685f9..cc22e22139 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -20,7 +20,7 @@ import debug # pyflakes:ignore from ietf.doc.expire import get_expired_drafts, send_expire_notice_for_draft, expire_draft -from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, RgDraftFactory, DocEventFactory +from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, WgDraftFactory, RgDraftFactory, DocEventFactory from ietf.doc.models import ( Document, DocReminder, DocEvent, ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, TelechatDocEvent, WriteupDocEvent, DocRelationshipName, IanaExpertDocEvent ) @@ -1483,6 +1483,42 @@ def test_confirm_submission(self): self.assertTrue("aread@" in outbox[-1]['To']) self.assertTrue("iesg-secretary@" in outbox[-1]['Cc']) + def test_confirm_submission_no_doc_ad(self): + url = urlreverse('ietf.doc.views_draft.to_iesg', kwargs=dict(name=self.docname)) + self.client.login(username="marschairman", password="marschairman+password") + + doc = Document.objects.get(name=self.docname) + RoleFactory(name_id='ad', group=doc.group, person=doc.ad) + e = DocEvent(type="changed_document", by=doc.ad, doc=doc, rev=doc.rev, desc="Remove doc AD") + e.save() + doc.ad = None + doc.save_with_history([e]) + + docevents_pre = set(doc.docevent_set.all()) + mailbox_before = len(outbox) + + r = self.client.post(url, dict(confirm="1")) + self.assertEqual(r.status_code, 302) + + doc = Document.objects.get(name=self.docname) + self.assertTrue(doc.get_state('draft-iesg').slug=='pub-req') + self.assertTrue(doc.get_state('draft-stream-ietf').slug=='sub-pub') + + self.assertCountEqual(doc.action_holders.all(), [doc.ad]) + + new_docevents = set(doc.docevent_set.all()) - docevents_pre + self.assertEqual(len(new_docevents), 5) + new_docevent_type_count = Counter([e.type for e in new_docevents]) + self.assertEqual(new_docevent_type_count['changed_state'],2) + self.assertEqual(new_docevent_type_count['started_iesg_process'],1) + self.assertEqual(new_docevent_type_count['changed_action_holders'], 1) + self.assertEqual(new_docevent_type_count['changed_document'], 1) + + self.assertEqual(len(outbox), mailbox_before + 1) + self.assertTrue("Publication has been requested" in outbox[-1]['Subject']) + self.assertTrue("aread@" in outbox[-1]['To']) + self.assertTrue("iesg-secretary@" in outbox[-1]['Cc']) + class RequestPublicationTests(TestCase): @@ -2125,3 +2161,13 @@ def test_shepherd_writeup_generation(self): self.assertContains(r, "for Group Documents", status_code=200) r = self.client.post(url,dict(reset_text='')) self.assertContains(r, "for Group Documents", status_code=200) + +class EditorialDraftMetadataTests(TestCase): + def test_editorial_metadata(self): + draft = EditorialDraftFactory() + url = urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=draft.name)) + r = self.client.get(url) + q = PyQuery(r.content) + top_level_metadata_headings = q("tbody>tr>th:first-child").text() + self.assertNotIn("IESG", top_level_metadata_headings) + self.assertNotIn("IANA", top_level_metadata_headings) diff --git a/ietf/doc/tests_material.py b/ietf/doc/tests_material.py index 2f057573ba..065ff09a98 100644 --- a/ietf/doc/tests_material.py +++ b/ietf/doc/tests_material.py @@ -6,19 +6,21 @@ import shutil import io +from mock import call, patch from pathlib import Path from pyquery import PyQuery import debug # pyflakes:ignore from django.conf import settings +from django.test import override_settings from django.urls import reverse as urlreverse from django.utils import timezone from ietf.doc.models import Document, State, NewRevisionDocEvent from ietf.group.factories import RoleFactory from ietf.group.models import Group -from ietf.meeting.factories import MeetingFactory, SessionFactory +from ietf.meeting.factories import MeetingFactory, SessionFactory, SessionPresentationFactory from ietf.meeting.models import Meeting, SessionPresentation, SchedulingEvent from ietf.name.models import SessionStatusName from ietf.person.models import Person @@ -135,19 +137,47 @@ def test_change_state(self): doc = Document.objects.get(name=doc.name) self.assertEqual(doc.get_state_slug(), "deleted") - def test_edit_title(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") + @patch("ietf.doc.views_material.SlidesManager") + def test_edit_title(self, mock_slides_manager_cls): doc = self.create_slides() url = urlreverse('ietf.doc.views_material.edit_material', kwargs=dict(name=doc.name, action="title")) login_testing_unauthorized(self, "secretary", url) + self.assertFalse(mock_slides_manager_cls.called) # post r = self.client.post(url, dict(title="New title")) self.assertEqual(r.status_code, 302) doc = Document.objects.get(name=doc.name) self.assertEqual(doc.title, "New title") + self.assertFalse(mock_slides_manager_cls.return_value.send_update.called) - def test_revise(self): + # assign to a session to see that it now sends updates to Meetecho + session = SessionPresentationFactory(session__group=doc.group, document=doc).session + + # Grab the title on the slides when the API call was made (to be sure it's not before it was updated) + titles_sent = [] + mock_slides_manager_cls.return_value.send_update.side_effect = lambda sess: titles_sent.extend( + list(sess.presentations.values_list("document__title", flat=True)) + ) + + r = self.client.post(url, dict(title="Newer title")) + self.assertEqual(r.status_code, 302) + doc = Document.objects.get(name=doc.name) + self.assertEqual(doc.title, "Newer title") + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + call(session), + ) + self.assertEqual(titles_sent, ["Newer title"]) + + @override_settings(MEETECHO_API_CONFIG="fake settings") + @patch("ietf.doc.views_material.SlidesManager") + def test_revise(self, mock_slides_manager_cls): doc = self.create_slides() session = SessionFactory( @@ -165,11 +195,18 @@ def test_revise(self): url = urlreverse('ietf.doc.views_material.edit_material', kwargs=dict(name=doc.name, action="revise")) login_testing_unauthorized(self, "secretary", url) + self.assertFalse(mock_slides_manager_cls.called) content = "some text" test_file = io.StringIO(content) test_file.name = "unnamed.txt" + # Grab the title on the slides when the API call was made (to be sure it's not before it was updated) + titles_sent = [] + mock_slides_manager_cls.return_value.send_update.side_effect = lambda sess: titles_sent.extend( + list(sess.presentations.values_list("document__title", flat=True)) + ) + # post r = self.client.post(url, dict(title="New title", abstract="New abstract", @@ -180,6 +217,14 @@ def test_revise(self): self.assertEqual(doc.rev, "02") self.assertEqual(doc.title, "New title") self.assertEqual(doc.get_state_slug(), "active") + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.send_update.call_args, + call(session), + ) + self.assertEqual(titles_sent, ["New title"]) with io.open(os.path.join(doc.get_file_path(), doc.name + "-" + doc.rev + ".txt")) as f: self.assertEqual(f.read(), content) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 1c2e6c7ace..d9aca94e86 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -380,6 +380,25 @@ def test_assign_reviewer_after_reject(self): reviewer_label = q("option[value=\"{}\"]".format(reviewer_email.address)).text().lower() self.assertIn("rejected review of document before", reviewer_label) + def test_assign_reviewer_after_withdraw(self): + doc = WgDraftFactory() + review_team = ReviewTeamFactory() + rev_role = RoleFactory(group=review_team,person__user__username='reviewer',person__user__email='reviewer@example.com',name_id='reviewer') + RoleFactory(group=review_team,person__user__username='reviewsecretary',name_id='secr') + review_req = ReviewRequestFactory(team=review_team,doc=doc) + reviewer = rev_role.person.email_set.first() + ReviewAssignmentFactory(review_request=review_req, state_id='withdrawn', reviewer=reviewer) + req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk }) + assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk }) + + login_testing_unauthorized(self, "reviewsecretary", assign_url) + r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk }) + self.assertRedirects(r, req_url) + review_req = reload_db_objects(review_req) + assignment = review_req.reviewassignment_set.last() + self.assertEqual(assignment.state, ReviewAssignmentStateName.objects.get(slug='assigned')) + self.assertEqual(review_req.state, ReviewRequestStateName.objects.get(slug='assigned')) + def test_previously_reviewed_replaced_doc(self): review_team = ReviewTeamFactory(acronym="reviewteam", name="Review Team", type_id="review", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut")) rev_role = RoleFactory(group=review_team,person__user__username='reviewer',person__user__email='reviewer@example.com',person__name='Some Reviewer',name_id='reviewer') 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/urls.py b/ietf/doc/urls.py index 496b2d8f3a..d29fd9da14 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -163,6 +163,7 @@ url(r'^%(name)s/edit/issueballot/rsab/$' % settings.URL_REGEXPS, views_ballot.issue_rsab_ballot), url(r'^%(name)s/edit/closeballot/rsab/$' % settings.URL_REGEXPS, views_ballot.close_rsab_ballot), + url(r'^help/state/?$', views_help.state_index), url(r'^help/state/(?P[\w-]+)/$', views_help.state_help), url(r'^help/relationships/$', views_help.relationship_help), url(r'^help/relationships/(?P\w+)/$', views_help.relationship_help), diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index d93c9f4531..4872f41fc4 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 @@ -1062,6 +1062,8 @@ def build_file_urls(doc: Union[Document, DocHistory]): base = settings.IETF_ID_ARCHIVE_URL file_urls = [] for t in found_types: + if t == "ps": # Postscript might have been submitted but should not be displayed in the list of URLs + continue label = "plain text" if t == "txt" else t file_urls.append((label, base + doc.name + "-" + doc.rev + "." + t)) @@ -1081,29 +1083,26 @@ def build_file_urls(doc: Union[Document, DocHistory]): return file_urls, found_types -def augment_docs_and_user_with_user_info(docs, user): +def augment_docs_and_person_with_person_info(docs, person): """Add attribute to each document with whether the document is tracked - or has a review wish by the user or not, and the review teams the user is on.""" + or has a review wish by the person or not, and the review teams the person is on.""" tracked = set() review_wished = set() - - if user and user.is_authenticated: - user.review_teams = Group.objects.filter( - reviewteamsettings__isnull=False, role__person__user=user, role__name='reviewer') - doc_pks = [d.pk for d in docs] - clist = CommunityList.objects.filter(user=user).first() - if clist: - tracked.update( - docs_tracked_by_community_list(clist).filter(pk__in=doc_pks).values_list("pk", flat=True)) + # used in templates + person.review_teams = Group.objects.filter( + reviewteamsettings__isnull=False, role__person=person, role__name='reviewer') - try: - wishes = ReviewWish.objects.filter(person=Person.objects.get(user=user)) - wishes = wishes.filter(doc__pk__in=doc_pks).values_list("doc__pk", flat=True) - review_wished.update(wishes) - except Person.DoesNotExist: - pass + doc_pks = [d.pk for d in docs] + clist = CommunityList.objects.filter(person=person).first() + if clist: + tracked.update( + docs_tracked_by_community_list(clist).filter(pk__in=doc_pks).values_list("pk", flat=True)) + + wishes = ReviewWish.objects.filter(person=person) + wishes = wishes.filter(doc__pk__in=doc_pks).values_list("doc__pk", flat=True) + review_wished.update(wishes) for d in docs: d.tracked_in_personal_community_list = d.pk in tracked @@ -1261,3 +1260,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/doc/utils_search.py b/ietf/doc/utils_search.py index 5a6a43a63b..59b64ad307 100644 --- a/ietf/doc/utils_search.py +++ b/ietf/doc/utils_search.py @@ -11,7 +11,7 @@ from ietf.doc.models import Document, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent, DocTypeName from ietf.doc.expire import expirable_drafts -from ietf.doc.utils import augment_docs_and_user_with_user_info +from ietf.doc.utils import augment_docs_and_person_with_person_info from ietf.meeting.models import SessionPresentation, Meeting, Session from ietf.review.utils import review_assignments_to_list_for_docs from ietf.utils.timezone import date_today @@ -181,7 +181,7 @@ def augment_docs_with_related_docs_info(docs): originalDoc = d.related_that_doc('conflrev')[0] d.pages = originalDoc.pages -def prepare_document_table(request, docs, query=None, max_results=200): +def prepare_document_table(request, docs, query=None, max_results=200, show_ad_and_shepherd=True): """Take a queryset of documents and a QueryDict with sorting info and return list of documents with attributes filled in for displaying a full table of information about the documents, plus @@ -199,7 +199,8 @@ def prepare_document_table(request, docs, query=None, max_results=200): docs = docs[:max_results] fill_in_document_table_attributes(docs) - augment_docs_and_user_with_user_info(docs, request.user) + if request.user.is_authenticated and hasattr(request.user, "person"): + augment_docs_and_person_with_person_info(docs, request.user.person) augment_docs_with_related_docs_info(docs) meta = {} @@ -259,12 +260,14 @@ def num(i): if len(docs) == max_results: meta['max'] = max_results - meta['headers'] = [{'title': 'Document', 'key':'document'}, - {'title': 'Title', 'key':'title'}, - {'title': 'Date', 'key':'date'}, - {'title': 'Status', 'key':'status'}, - {'title': 'IPR', 'key':'ipr'}, - {'title': 'AD / Shepherd', 'key':'ad'}] + meta['headers'] = [{'title': 'Document', 'key': 'document'}, + {'title': 'Title', 'key': 'title'}, + {'title': 'Date', 'key': 'date'}, + {'title': 'Status', 'key': 'status'}, + {'title': 'IPR', 'key': 'ipr'}] + if show_ad_and_shepherd: + meta['headers'].append({'title': 'AD / Shepherd', 'key': 'ad'}) + meta['show_ad_and_shepherd'] = show_ad_and_shepherd if query and hasattr(query, "urlencode"): # fed a Django QueryDict d = query.copy() diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 665393c24e..551ec0cc5b 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2023, All Rights Reserved +# Copyright The IETF Trust 2009-2024, All Rights Reserved # -*- coding: utf-8 -*- # # Parts Copyright (C) 2009-2010 Nokia Corporation and/or its subsidiary(-ies). @@ -40,9 +40,9 @@ import os import re -from urllib.parse import quote from pathlib import Path +from django.db.models import Max from django.http import HttpResponse, Http404 from django.shortcuts import render, get_object_or_404, redirect from django.template.loader import render_to_string @@ -51,7 +51,6 @@ from django import forms from django.contrib.staticfiles import finders - import debug # pyflakes:ignore from ietf.doc.models import ( Document, DocHistory, DocEvent, BallotDocEvent, BallotType, @@ -63,7 +62,7 @@ needed_ballot_positions, nice_consensus, update_telechat, has_same_ballot, get_initial_notify, make_notify_changed_event, make_rev_history, default_consensus, add_events_message_info, get_unicode_document_content, - augment_docs_and_user_with_user_info, irsg_needed_ballot_positions, add_action_holder_change_event, + augment_docs_and_person_with_person_info, irsg_needed_ballot_positions, add_action_holder_change_event, build_file_urls, update_documentauthors, fuzzy_find_documents, bibxml_for_draft) from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible @@ -76,13 +75,14 @@ from ietf.doc.forms import TelechatForm, NotifyForm, ActionHoldersForm, DocAuthorForm, DocAuthorChangeBasisForm from ietf.doc.mails import email_comment, email_remind_action_holders from ietf.mailtrigger.utils import gather_relevant_expansions -from ietf.meeting.models import Session +from ietf.meeting.models import Session, SessionPresentation from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions, add_event_info_to_session_qs from ietf.review.models import ReviewAssignment from ietf.review.utils import can_request_review_of_doc, review_assignments_to_list_for_docs, review_requests_to_list_for_docs from ietf.review.utils import no_review_from_teams_on_doc from ietf.utils import markup_txt, log, markdown from ietf.utils.draft import PlaintextDraft +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager from ietf.utils.response import permission_denied from ietf.utils.text import maybe_split from ietf.utils.timezone import date_today @@ -288,7 +288,8 @@ def document_main(request, name, rev=None, document_html=False): presentations = doc.future_presentations() - augment_docs_and_user_with_user_info([doc], request.user) + if request.user.is_authenticated and hasattr(request.user, "person"): + augment_docs_and_person_with_person_info([doc], request.user.person) exp_comment = doc.latest_event(IanaExpertDocEvent,type="comment") iana_experts_comment = exp_comment and exp_comment.desc @@ -324,6 +325,9 @@ def document_main(request, name, rev=None, document_html=False): submission = group.acronym submission = '%s' % (group.about_url(), submission) + draft = doc.came_from_draft() + mailto_name = draft.name if draft else None + return render(request, "doc/document_rfc.html" if document_html is False else "doc/document_html.html", dict(doc=doc, document_html=document_html, @@ -356,7 +360,8 @@ def document_main(request, name, rev=None, document_html=False): iana_experts_comment=iana_experts_comment, presentations=presentations, diff_revisions=diff_revisions, - submission=submission + submission=submission, + mailto_name=mailto_name, )) elif doc.type_id == "draft": @@ -476,13 +481,6 @@ def document_main(request, name, rev=None, document_html=False): can_submit_unsolicited_review_for_teams = Group.objects.filter( reviewteamsettings__isnull=False, role__person__user=request.user, role__name='secr') - # mailing list search archive - search_archive = "www.ietf.org/mail-archive/web/" - if doc.stream_id == "ietf" and group.type_id == "wg" and group.list_archive: - search_archive = group.list_archive - - search_archive = quote(search_archive, safe="~") - # conflict reviews conflict_reviews = [r.source.name for r in interesting_relations_that.filter(relationship="conflrev")] @@ -581,7 +579,8 @@ def document_main(request, name, rev=None, document_html=False): elif can_edit_stream_info and (iesg_state_slug in ('idexists','watching')): actions.append(("Submit to IESG for Publication", urlreverse('ietf.doc.views_draft.to_iesg', kwargs=dict(name=doc.name)))) - augment_docs_and_user_with_user_info([doc], request.user) + if request.user.is_authenticated and hasattr(request.user, "person"): + augment_docs_and_person_with_person_info([doc], request.user.person) published = doc.latest_event(type="published_rfc") # todo rethink this now that published_rfc is on rfc started_iesg_process = doc.latest_event(type="started_iesg_process") @@ -700,7 +699,6 @@ def document_main(request, name, rev=None, document_html=False): iana_experts_comment=iana_experts_comment, started_iesg_process=started_iesg_process, shepherd_writeup=shepherd_writeup, - search_archive=search_archive, actions=actions, presentations=presentations, review_assignments=review_assignments, @@ -831,7 +829,7 @@ def document_main(request, name, rev=None, document_html=False): sorted_relations=sorted_relations, )) - elif doc.type_id in ("slides", "agenda", "minutes", "bluesheets", "procmaterials",): + elif doc.type_id in ("slides", "agenda", "minutes", "narrativeminutes", "bluesheets", "procmaterials",): can_manage_material = can_manage_materials(request.user, doc.group) presentations = doc.future_presentations() if doc.uploaded_filename: @@ -915,9 +913,9 @@ def document_main(request, name, rev=None, document_html=False): elif doc.type_id in ("chatlog", "polls"): if isinstance(doc,DocHistory): - session = doc.doc.sessionpresentation_set.last().session + session = doc.doc.presentations.last().session else: - session = doc.sessionpresentation_set.last().session + session = doc.presentations.last().session pathname = Path(session.meeting.get_materials_path()) / doc.type_id / doc.uploaded_filename content = get_unicode_document_content(doc.name, str(pathname)) return render( @@ -942,7 +940,7 @@ def document_main(request, name, rev=None, document_html=False): variants = set([match.name.split(".")[1] for match in Path(doc.get_file_path()).glob(f"{basename}.*")]) inlineable = any([ext in variants for ext in ["md", "txt"]]) if inlineable: - content = markdown.markdown(doc.text_or_error()) + content = markdown.liberal_markdown(doc.text_or_error()) else: content = "No format available to display inline" if "pdf" in variants: @@ -1064,7 +1062,10 @@ def document_pdfized(request, name, rev=None, ext=None): if not os.path.exists(doc.get_file_name()): raise Http404("File not found: %s" % doc.get_file_name()) - pdf = doc.pdfized() + try: + pdf = doc.pdfized() + except Exception: + return render(request, "doc/weasyprint_failed.html") if pdf: return HttpResponse(pdf,content_type='application/pdf') else: @@ -1262,6 +1263,9 @@ def document_bibtex(request, name, rev=None): doc = get_object_or_404(Document, name=name) + if doc.type_id not in ["rfc", "draft"]: + raise Http404() + doi = None draft_became_rfc = None replaced_by = None @@ -2050,7 +2054,7 @@ def __init__(self, *args, **kwargs): def edit_sessionpresentation(request,name,session_id): doc = get_object_or_404(Document, name=name) - sp = get_object_or_404(doc.sessionpresentation_set, session_id=session_id) + sp = get_object_or_404(doc.presentations, session_id=session_id) if not sp.session.can_manage_materials(request.user): raise Http404 @@ -2067,7 +2071,13 @@ def edit_sessionpresentation(request,name,session_id): if form.is_valid(): new_selection = form.cleaned_data['version'] if initial['version'] != new_selection: - doc.sessionpresentation_set.filter(pk=sp.pk).update(rev=None if new_selection=='current' else new_selection) + doc.presentations.filter(pk=sp.pk).update(rev=None if new_selection=='current' else new_selection) + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.send_update(sp.session) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.send_update(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "Revision for session %s changed to %s" % (sp.session,new_selection) c.save() @@ -2079,7 +2089,7 @@ def edit_sessionpresentation(request,name,session_id): def remove_sessionpresentation(request,name,session_id): doc = get_object_or_404(Document, name=name) - sp = get_object_or_404(doc.sessionpresentation_set, session_id=session_id) + sp = get_object_or_404(doc.presentations, session_id=session_id) if not sp.session.can_manage_materials(request.user): raise Http404 @@ -2088,7 +2098,13 @@ def remove_sessionpresentation(request,name,session_id): raise Http404 if request.method == 'POST': - doc.sessionpresentation_set.filter(pk=sp.pk).delete() + doc.presentations.filter(pk=sp.pk).delete() + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(sp.session, doc) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.delete(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "Removed from session: %s" % (sp.session) c.save() @@ -2112,7 +2128,7 @@ def add_sessionpresentation(request,name): version_choices.insert(0,('current','Current at the time of the session')) sessions = get_upcoming_manageable_sessions(request.user) - sessions = sort_sessions([s for s in sessions if not s.sessionpresentation_set.filter(document=doc).exists()]) + sessions = sort_sessions([s for s in sessions if not s.presentations.filter(document=doc).exists()]) if doc.group: sessions = sorted(sessions,key=lambda x:0 if x.group==doc.group else 1) @@ -2125,7 +2141,25 @@ def add_sessionpresentation(request,name): session_id = session_form.cleaned_data['session'] version = version_form.cleaned_data['version'] rev = None if version=='current' else version - doc.sessionpresentation_set.create(session_id=session_id,rev=rev) + if doc.type_id == "slides": + max_order = SessionPresentation.objects.filter( + document__type='slides', + session__pk=session_id, + ).aggregate(Max('order'))['order__max'] or 0 + order = max_order + 1 + else: + order = 0 + sp = doc.presentations.create( + session_id=session_id, + rev=rev, + order=order, + ) + if doc.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.add(sp.session, doc, order=sp.order) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.add(): {err}") c = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person) c.desc = "%s to session: %s" % ('Added -%s'%rev if rev else 'Added', Session.objects.get(pk=session_id)) c.save() @@ -2185,13 +2219,31 @@ def idnits2_state(request, name, rev=None): if doc.type_id == "rfc": draft = doc.came_from_draft() if draft: - zero_revision = NewRevisionDocEvent.objects.filter(doc=draft,rev='00').first() + zero_revision = NewRevisionDocEvent.objects.filter( + doc=draft, rev="00" + ).first() else: - zero_revision = NewRevisionDocEvent.objects.filter(doc=doc,rev='00').first() + zero_revision = NewRevisionDocEvent.objects.filter(doc=doc, rev="00").first() if zero_revision: doc.created = zero_revision.time else: - doc.created = doc.docevent_set.order_by('-time').first().time + if doc.type_id == "draft": + if doc.became_rfc(): + interesting_event = ( + doc.became_rfc() + .docevent_set.filter(type="published_rfc") + .order_by("-time") + .first() + ) + else: + interesting_event = doc.docevent_set.order_by( + "-time" + ).first() # Is taking the most _recent_ instead of the oldest event correct? + else: # doc.type_id == "rfc" + interesting_event = ( + doc.docevent_set.filter(type="published_rfc").order_by("-time").first() + ) + doc.created = interesting_event.time if doc.std_level: doc.deststatus = doc.std_level.name elif doc.intended_std_level: @@ -2199,8 +2251,16 @@ def idnits2_state(request, name, rev=None): else: text = doc.text() if text: - parsed_draft = PlaintextDraft(text=doc.text(), source=name, name_from_source=False) + parsed_draft = PlaintextDraft( + text=doc.text(), source=name, name_from_source=False + ) doc.deststatus = parsed_draft.get_status() else: - doc.deststatus="Unknown" - return render(request, 'doc/idnits2-state.txt', context={'doc':doc}, content_type='text/plain;charset=utf-8') + doc.deststatus = "Unknown" + return render( + request, + "doc/idnits2-state.txt", + context={"doc": doc}, + content_type="text/plain;charset=utf-8", + ) + diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 4f6659af9f..ea30e7bd2d 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -560,22 +560,19 @@ def to_iesg(request,name): if request.method == 'POST': if request.POST.get("confirm", ""): - by = request.user.person events = [] - - changes = [] + def doc_event(type, by, doc, desc): + return DocEvent.objects.create(type=type, by=by, doc=doc, rev=doc.rev, desc=desc) if doc.get_state_slug("draft-iesg") == "idexists": - e = DocEvent() - e.type = "started_iesg_process" - e.by = by - e.doc = doc - e.rev = doc.rev - e.desc = "Document is now in IESG state %s" % target_state['iesg'].name - e.save() - events.append(e) + events.append(doc_event("started_iesg_process", by, doc, f"Document is now in IESG state {target_state['iesg'].name}")) + + # do this first, so AD becomes action holder + if not doc.ad == ad : + doc.ad = ad + events.append(doc_event("changed_document", by, doc, f"Responsible AD changed to {doc.ad}")) for state_type in ['draft-iesg','draft-stream-ietf']: prev_state=doc.get_state(state_type) @@ -587,25 +584,14 @@ def to_iesg(request,name): events.append(e) events.append(add_state_change_event(doc=doc,by=by,prev_state=prev_state,new_state=new_state)) - if not doc.ad == ad : - doc.ad = ad - changes.append("Responsible AD changed to %s" % doc.ad) - if not doc.notify == notify : doc.notify = notify - changes.append("State Change Notice email list changed to %s" % doc.notify) + events.append(doc_event("changed_document", by, doc, f"State Change Notice email list changed to {doc.notify}")) # Get the last available writeup previous_writeup = doc.latest_event(WriteupDocEvent,type="changed_protocol_writeup") if previous_writeup != None: - changes.append(previous_writeup.text) - - for c in changes: - e = DocEvent(doc=doc, rev=doc.rev, by=by) - e.desc = c - e.type = "changed_document" - e.save() - events.append(e) + events.append(doc_event("changed_document", by, doc, previous_writeup.text)) doc.save_with_history(events) diff --git a/ietf/doc/views_help.py b/ietf/doc/views_help.py index 73cdcdd20f..34d29aaccb 100644 --- a/ietf/doc/views_help.py +++ b/ietf/doc/views_help.py @@ -1,5 +1,7 @@ # Copyright The IETF Trust 2013-2023, All Rights Reserved +import debug # pyflakes: ignore + from django.shortcuts import render, get_object_or_404 from django.http import Http404 @@ -7,6 +9,18 @@ from ietf.name.models import DocRelationshipName, DocTagName from ietf.doc.utils import get_tags_for_stream_id +def state_index(request): + types = StateType.objects.all() + names = [ type.slug for type in types ] + for type in types: + if "-" in type.slug and type.slug.split('-',1)[0] in names: + type.stategroups = None + else: + groups = StateType.objects.filter(slug__startswith=type.slug) + type.stategroups = [ g.slug[len(type.slug)+1:] for g in groups if not g == type ] or "" + + return render(request, 'doc/state_index.html', {"types": types}) + def state_help(request, type=None): slug, title = { "draft-iesg": ("draft-iesg", "IESG States for Internet-Drafts"), @@ -18,12 +32,36 @@ def state_help(request, type=None): "draft-stream-irtf": ("draft-stream-irtf", "IRTF Stream States for Internet-Drafts"), "draft-stream-ise": ("draft-stream-ise", "ISE Stream States for Internet-Drafts"), "draft-stream-iab": ("draft-stream-iab", "IAB Stream States for Internet-Drafts"), + "draft-stream-editorial": ("draft-stream-editorial", "Editorial Stream States for Internet-Drafts"), "charter": ("charter", "Charter States"), "conflict-review": ("conflrev", "Conflict Review States"), "status-change": ("statchg", "RFC Status Change States"), "bofreq": ("bofreq", "BOF Request States"), "procmaterials": ("procmaterials", "Proceedings Materials States"), - "statement": {"statement", "Statement States"} + "statement": ("statement", "Statement States"), + "slides": ("slides", "Slides States"), + "minutes": ("minutes", "Minutes States"), + "liai-att": ("liai-att", "Liaison Attachment States"), + "recording": ("recording", "Recording States"), + "bluesheets": ("bluesheets", "Bluesheets States"), + "reuse_policy": ("reuse_policy", "Reuse Policy States"), + "review": ("review", "Review States"), + "liaison": ("liaison", "Liaison States"), + "shepwrit": ("shepwrit", "Shapherd Writeup States"), + "bofreq": ("bofreq", "BOF Request States"), + "procmaterials": ("procmaterials", "Proceedings Materials States"), + "chatlog": ("chatlog", "Chat Log States"), + "polls": ("polls", "Polls States"), + "statement": ("statement", "Statement States"), + "rfc": ("rfc", "RFC States"), + "bcp": ("bcp", "BCP States"), + "std": ("std", "STD States"), + "fyi": ("fyi", "FYI States"), + "narrativeminutes": ("narrativeminutes", "Narrative Minutes States"), + "draft": ("draft", "Draft States"), + "statchg": ("statchg", "Status Change States"), + "agenda": ("agenda", "Agenda States"), + "conflrev": ("conflrev", "Conflict Review States") }.get(type, (None, None)) state_type = get_object_or_404(StateType, slug=slug) diff --git a/ietf/doc/views_material.py b/ietf/doc/views_material.py index 5b16c247b5..b646ecf2fc 100644 --- a/ietf/doc/views_material.py +++ b/ietf/doc/views_material.py @@ -8,6 +8,7 @@ import re from django import forms +from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import Http404 from django.shortcuts import render, get_object_or_404, redirect @@ -21,9 +22,13 @@ from ietf.doc.utils import add_state_change_event, check_common_doc_name_rules from ietf.group.models import Group from ietf.group.utils import can_manage_materials +from ietf.utils import log +from ietf.utils.decorators import ignore_view_kwargs +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager from ietf.utils.response import permission_denied @login_required +@ignore_view_kwargs("group_type") def choose_material_type(request, acronym): group = get_object_or_404(Group, acronym=acronym) if not group.features.has_nonsession_materials: @@ -91,6 +96,7 @@ def clean_name(self): return name @login_required +@ignore_view_kwargs("group_type") def edit_material(request, name=None, acronym=None, action=None, doc_type=None): # the materials process is not very developed, so at the moment we # handle everything through the same view/form @@ -110,6 +116,8 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): valid_doctypes = ['procmaterials'] if group is not None: valid_doctypes.extend(['minutes','agenda','bluesheets']) + if group.acronym=="iesg": + valid_doctypes.append("narrativeminutes") valid_doctypes.extend(group.features.material_types) if document_type.slug not in valid_doctypes: @@ -118,6 +126,8 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): if not can_manage_materials(request.user, group): permission_denied(request, "You don't have permission to access this view") + sessions_with_slide_title_updates = set() + if request.method == 'POST': form = UploadMaterialForm(document_type, action, group, doc, request.POST, request.FILES) @@ -170,6 +180,9 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): e.desc += " from %s" % prev_title e.save() events.append(e) + if doc.type_id == "slides": + for sp in doc.presentations.all(): + sessions_with_slide_title_updates.add(sp.session) if prev_abstract != doc.abstract: e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='changed_document') @@ -187,6 +200,16 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None): if events: doc.save_with_history(events) + # Call Meetecho API if any session slides titles changed + if sessions_with_slide_title_updates and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + for session in sessions_with_slide_title_updates: + try: + # SessionPresentations are unique over (session, document) so there will be no duplicates + sm.send_update(session) + except MeetechoAPIError as err: + log.log(f"Error in SlidesManager.send_update(): {err}") + return redirect("ietf.doc.views_doc.document_main", name=doc.name) else: form = UploadMaterialForm(document_type, action, group, doc) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index f1f9d7be4e..04c558ce3e 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -1053,7 +1053,11 @@ def edit_deadline(request, name, request_id): if form.is_valid(): if form.cleaned_data['deadline'] != old_deadline: form.save() - subject = "Deadline changed: {} {} review of {}-{}".format(review_req.team.acronym.capitalize(),review_req.type.name.lower(), review_req.doc.name, review_req.requested_rev) + subject = f"Deadline changed: {review_req.team.acronym.capitalize()} {review_req.type.name.lower()} review of {review_req.doc.name}" + if review_req.requested_rev: + subject += f"-{review_req.requested_rev}" + descr = "Deadine changed from {} to {}".format(old_deadline, review_req.deadline) + update_change_reason(review_req, descr) msg = render_to_string("review/deadline_changed.txt", { "review_req": review_req, "old_deadline": old_deadline, diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index 2e4231c5ac..422e38f7dd 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -39,7 +39,9 @@ import copy import operator +from collections import defaultdict from functools import reduce + from django import forms from django.conf import settings from django.core.cache import cache, caches @@ -211,6 +213,9 @@ def retrieve_search_results(form, all_types=False): Q(targets_related__source__title__icontains=singlespace, targets_related__relationship_id="contains"), ]) + if query["rfcs"]: + queries.extend([Q(targets_related__source__name__icontains=look_for, targets_related__relationship_id="became_rfc")]) + combined_query = reduce(operator.or_, queries) docs = docs.filter(combined_query).distinct() @@ -357,32 +362,52 @@ def cached_redirect(cache_key, url): return cached_redirect(cache_key, urlreverse('ietf.doc.views_search.search') + search_args) -def state_name(doc_type, state, shorten=True): - name = "" - # Note doc_type rfc here is _not_ necessarily Document.type - for some callers - # it is a type derived from draft... The ad_workload view needs more rework so that - # the code isn't having to shadow-box so much. - if doc_type == "rfc": - if state == "rfc": - name = "RFC" - if name == "": - s = State.objects.filter(type="rfc",slug=state).first() - if s: - name = s.name - if name == "": - name = State.objects.get(type__in=["draft", "draft-iesg"], slug=state).name - elif doc_type == "draft" and state not in ["rfc", "expired"]: - name = State.objects.get(type__in=["draft", "draft-iesg"], slug=state).name - elif doc_type == "draft" and state == "rfc": - name = "RFC" - elif doc_type == "conflrev" and state.startswith("appr"): - name = "Approved" - else: - name = State.objects.get(type=doc_type, slug=state).name +def get_state_name_calculator(): + """Get a function to calculate state names + + Queries the database once when called, then uses cached look-up table for name calculations. + """ + # state_lut always has at least rfc, draft, and draft-iesg keys + state_lut = defaultdict(dict, **{"rfc": {}, "draft":{}, "draft-iesg": {}}) + for state in State.objects.filter(used=True): + state_lut[state.type_id][state.slug] = state.name + state_lut = dict(state_lut) # convert to dict to freeze key changes + + def _get_state_name(doc_type, state_slug): + """Get display name for a doc type / state slug + + Note doc_type rfc here is _not_ necessarily Document.type - for some callers + it is a type derived from draft... The ad_workload view needs more rework so that + the code isn't having to shadow-box so much. + """ + if doc_type == "rfc": + if state_slug == "rfc": + return "RFC" + elif state_slug in state_lut["rfc"]: + return state_lut["rfc"][state_slug] + else: + return state_lut["draft"].get( + state_slug, + state_lut["draft-iesg"][state_slug], + ) + elif doc_type == "draft" and state_slug not in ["rfc", "expired"]: + return state_lut["draft"].get( + state_slug, + state_lut["draft-iesg"][state_slug], + ) + elif doc_type == "draft" and state_slug == "rfc": + return "RFC" + elif doc_type == "conflrev" and state_slug.startswith("appr"): + return "Approved" + else: + return state_lut[doc_type][state_slug] + + # return the function as a closure + return _get_state_name - if not shorten: - return name +def shorten_state_name(name): + """Get abbreviated display name for a state""" for pat, sub in [ (r" \(Internal Steering Group/IAB Review\)", ""), ("Writeup", "Write-up"), @@ -409,31 +434,27 @@ def state_name(doc_type, state, shorten=True): (r"\(Message to Community, Selected by Secretariat\)", ""), ]: name = re.sub(pat, sub, name) - return name.strip() -STATE_SLUGS = { - dt: {state_name(dt, ds, shorten=False): ds for ds in AD_WORKLOAD[dt]} # type: ignore - for dt in AD_WORKLOAD -} - - -def state_to_doc_type(state): - for dt in STATE_SLUGS: - if state in STATE_SLUGS[dt]: - return dt - return None - - -IESG_STATES = State.objects.filter(type="draft-iesg").values_list("name", flat=True) - - def date_to_bucket(date, now, num_buckets): return num_buckets - int((now.date() - date.date()).total_seconds() / 60 / 60 / 24) def ad_workload(request): + _calculate_state_name = get_state_name_calculator() + IESG_STATES = State.objects.filter(type="draft-iesg").values_list("name", flat=True) + STATE_SLUGS = { + dt: {_calculate_state_name(dt, ds): ds for ds in AD_WORKLOAD[dt]} # type: ignore + for dt in AD_WORKLOAD.keys() + } + + def _state_to_doc_type(state): + for dt in STATE_SLUGS: + if state in STATE_SLUGS[dt]: + return dt + return None + # number of days (= buckets) to show in the graphs days = 120 if has_role(request.user, ["Area Director", "Secretariat"]) else 1 now = timezone.now() @@ -468,11 +489,11 @@ def ad_workload(request): state = doc_state(doc) state_events = doc.docevent_set.filter( - Q(type="started_iesg_process") - | Q(type="changed_state") - | Q(type="published_rfc") - | Q(type="closed_ballot"), - ).order_by("-time") + type__in=["started_iesg_process", "changed_state", "closed_ballot"] + ) + if doc.became_rfc(): + state_events = state_events | doc.became_rfc().docevent_set.filter(type="published_rfc") + state_events = state_events.order_by("-time") # compute state history for drafts last = now @@ -480,7 +501,7 @@ def ad_workload(request): to_state = None if dt == "charter": if e.type == "closed_ballot": - to_state = state_name(dt, state, shorten=False) + to_state = _calculate_state_name(dt, state) elif e.desc.endswith("has been replaced"): # stop tracking last = e.time @@ -509,7 +530,7 @@ def ad_workload(request): to_state = "RFC" if dt == "rfc": - new_dt = state_to_doc_type(to_state) + new_dt = _state_to_doc_type(to_state) if new_dt is not None and new_dt != dt: dt = new_dt @@ -546,7 +567,9 @@ def ad_workload(request): metadata = [ { "type": (dt, doc_type_name(dt)), - "states": [(state, state_name(dt, state)) for state in ad.buckets[dt]], + "states": [ + (state, shorten_state_name(_calculate_state_name(dt, state))) for state in ad.buckets[dt] + ], "ads": ads, } for dt in AD_WORKLOAD @@ -588,9 +611,10 @@ def sort_key(doc): if not ad: raise Http404 - results, meta = prepare_document_table(request, Document.objects.filter(ad=ad)) + results, meta = prepare_document_table( + request, Document.objects.filter(ad=ad), max_results=500, show_ad_and_shepherd=False + ) results.sort(key=lambda d: sort_key(d)) - del meta["headers"][-1] # filter out some results results = [ @@ -608,13 +632,15 @@ def sort_key(doc): and ( r.get_state_slug("draft-iesg") == "dead" or r.get_state_slug("draft") == "repl" + or r.get_state_slug("draft") == "rfc" ) ) ] + _calculate_state_name = get_state_name_calculator() for d in results: dt = d.type.slug - d.search_heading = state_name(dt, doc_state(d), shorten=False) + d.search_heading = _calculate_state_name(dt, doc_state(d)) if d.search_heading != "RFC": d.search_heading += f" {doc_type_name(dt)}" @@ -687,7 +713,7 @@ def sort_key(doc): { "docs": results, "meta": meta, - "ad_name": ad.name, + "ad": ad, "blocked_docs": blocked_docs, "not_balloted_docs": not_balloted_docs, }, diff --git a/ietf/doc/views_statement.py b/ietf/doc/views_statement.py index 04adb5d1db..bf9f47ddfe 100644 --- a/ietf/doc/views_statement.py +++ b/ietf/doc/views_statement.py @@ -94,7 +94,7 @@ def require_field(f): ) if markdown_content != "": try: - _ = markdown.markdown(markdown_content) + _ = markdown.liberal_markdown(markdown_content) except Exception as e: raise forms.ValidationError(f"Markdown processing failed: {e}") diff --git a/ietf/group/admin.py b/ietf/group/admin.py index 5095b6b24d..fedec49d85 100644 --- a/ietf/group/admin.py +++ b/ietf/group/admin.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2010-2020, All Rights Reserved +# Copyright The IETF Trust 2010-2024, All Rights Reserved # -*- coding: utf-8 -*- import re @@ -72,6 +72,12 @@ def clean(self): 'Acronym is invalid. For groups that create documents, the acronym must be at least ' 'two characters and only contain lowercase letters and numbers starting with a letter.' ) + elif self.cleaned_data['type'].pk == 'sdo': + valid_re = r'^[a-z0-9][a-z0-9-]*[a-z0-9]$' + error_msg = ( + 'Acronym is invalid. It must be at least two characters and only contain lowercase ' + 'letters and numbers. It may contain hyphens, but that is discouraged.' + ) else: valid_re = r'^[a-z][a-z0-9-]*[a-z0-9]$' error_msg = ( diff --git a/ietf/group/management/commands/generate_group_aliases.py b/ietf/group/management/commands/generate_group_aliases.py deleted file mode 100755 index 630f35c441..0000000000 --- a/ietf/group/management/commands/generate_group_aliases.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright The IETF Trust 2012-2021, All Rights Reserved -# -*- coding: utf-8 -*- - -# This was written as a script by Markus Stenberg . -# It was turned into a management command by Russ Housley . - -import datetime -import io -import os -import shutil -import stat -import time - -from tempfile import mkstemp - -from django.conf import settings -from django.core.management.base import BaseCommand -from django.utils import timezone - -import debug # pyflakes:ignore - -from ietf.group.models import Group -from ietf.group.utils import get_group_ad_emails, get_group_role_emails, get_child_group_role_emails -from ietf.name.models import GroupTypeName -from ietf.utils.aliases import dump_sublist - -DEFAULT_YEARS = 5 -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'] -IETF_DOMAIN=['ietf.org', ] -IRTF_DOMAIN=['irtf.org', ] -IAB_DOMAIN=['iab.org', ] - -class Command(BaseCommand): - help = ('Generate the group-aliases and group-virtual files for Internet-Draft ' - 'mail aliases, placing them in the file configured in ' - 'settings.GROUP_ALIASES_PATH and settings.GROUP_VIRTUAL_PATH, ' - 'respectively. The generation includes aliases for groups that ' - 'have seen activity in the last %s years.' % (DEFAULT_YEARS)) - - def handle(self, *args, **options): - show_since = timezone.now() - datetime.timedelta(DEFAULT_YEARS*365) - - date = time.strftime("%Y-%m-%d_%H:%M:%S") - signature = '# Generated by %s at %s\n' % (os.path.abspath(__file__), date) - - ahandle, aname = mkstemp() - os.close(ahandle) - afile = io.open(aname,"w") - - vhandle, vname = mkstemp() - os.close(vhandle) - vfile = io.open(vname,"w") - - afile.write(signature) - vfile.write(signature) - vfile.write("%s anything\n" % settings.GROUP_VIRTUAL_DOMAIN) - - # Loop through each group type and build -ads and -chairs entries - for g in GROUP_TYPES: - domains = [] - domains += IETF_DOMAIN - if g in ('rg', 'rag'): - domains += IRTF_DOMAIN - if g == 'program': - domains += IAB_DOMAIN - - entries = Group.objects.filter(type=g).all() - active_entries = entries.filter(state__in=ACTIVE_STATES) - inactive_recent_entries = entries.exclude(state__in=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 NO_AD_GROUP_TYPES: - dump_sublist(afile, vfile, name+'-ads', domains, settings.GROUP_VIRTUAL_DOMAIN, get_group_ad_emails(e)) - # All group types have -chairs lists - dump_sublist(afile, vfile, name+'-chairs', domains, settings.GROUP_VIRTUAL_DOMAIN, get_group_role_emails(e, ['chair', 'secr'])) - - # 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=ACTIVE_STATES) - for area in active_areas: - name = area.acronym - area_ad_emails = get_group_role_emails(area, ['pre-ad', 'ad', 'chair']) - dump_sublist(afile, vfile, name+'-ads', IETF_DOMAIN, settings.GROUP_VIRTUAL_DOMAIN, area_ad_emails) - dump_sublist(afile, vfile, name+'-chairs', IETF_DOMAIN, settings.GROUP_VIRTUAL_DOMAIN, (get_child_group_role_emails(area, ['chair', 'secr']) | area_ad_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: - dump_sublist(afile, vfile, group.acronym+'-chairs', IETF_DOMAIN, settings.GROUP_VIRTUAL_DOMAIN, get_group_role_emails(group, ['chair', 'delegate'])) - - afile.close() - vfile.close() - - os.chmod(aname, stat.S_IWUSR|stat.S_IRUSR|stat.S_IRGRP|stat.S_IROTH) - os.chmod(vname, stat.S_IWUSR|stat.S_IRUSR|stat.S_IRGRP|stat.S_IROTH) - - shutil.move(aname, settings.GROUP_ALIASES_PATH) - shutil.move(vname, settings.GROUP_VIRTUAL_PATH) diff --git a/ietf/group/management/commands/import_iesg_appeals.py b/ietf/group/management/commands/import_iesg_appeals.py new file mode 100644 index 0000000000..1c4ebe3f83 --- /dev/null +++ b/ietf/group/management/commands/import_iesg_appeals.py @@ -0,0 +1,294 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +import csv +import datetime +import re +import shutil +import subprocess +import tempfile + +from pathlib import Path +import dateutil + +from django.conf import settings +from django.core.management import BaseCommand + +from ietf.group.models import Appeal, AppealArtifact + + +class Command(BaseCommand): + help = "Performs a one-time import of IESG appeals" + + def handle(self, *args, **options): + old_appeals_root = ( + "/a/www/www6/iesg/appeal" + if settings.SERVER_MODE == "production" + else "/assets/www6/iesg/appeal" + ) + tmpdir = tempfile.mkdtemp() + process = subprocess.Popen( + ["git", "clone", "https://github.com/kesara/iesg-scraper.git", tmpdir], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + sub_stdout, sub_stderr = process.communicate() + if not (Path(tmpdir) / "iesg_appeals" / "anderson-2006-03-08.md").exists(): + self.stdout.write( + "Git clone of the iesg-scraper directory did not go as expected" + ) + self.stdout.write("stdout:", sub_stdout) + self.stdout.write("stderr:", sub_stderr) + self.stdout.write(f"Clean up {tmpdir} manually") + exit(-1) + titles = [ + "Appeal: IESG Statement on Guidance on In-Person and Online Interim Meetings (John Klensin, 2023-08-15)", + "Appeal of current Guidance on in-Person and Online meetings (Ted Hardie, Alan Frindell, 2023-07-19)", + "Appeal re: URI Scheme Application and draft-mcsweeney-drop-scheme (Tim McSweeney, 2020-07-08)", + "Appeal to the IESG re WGLC of draft-ietf-spring-srv6-network-programming (Fernando Gont, Andrew Alston, and Sander Steffann, 2020-04-22)", + "Appeal re Protocol Action: 'URI Design and Ownership' to Best \nCurrent Practice (draft-nottingham-rfc7320bis-03.txt) (John Klensin; 2020-02-04)", + "Appeal of IESG Conflict Review process and decision on draft-mavrogiannopoulos-pkcs8-validated-parameters-02 (John Klensin; 2018-07-07)", + "Appeal of IESG decision to defer action and request that ISE publish draft-klensin-dns-function-considerations (John Klensin; 2017-11-29)", + 'Appeal to the IESG concerning its approval of the "draft-ietf-ianaplan-icg-response" (PDF file) (JFC Morfin; 2015-03-11)', + "Appeal re tzdist mailing list moderation (Tobias Conradi; 2014-08-28) / Withdrawn by Submitter", + "Appeal re draft-masotta-tftpexts-windowsize-opt (Patrick Masotta; 2013-11-14)", + "Appeal re draft-ietf-manet-nhdp-sec-threats (Abdussalam Baryun; 2013-06-19)", + "Appeal of decision to advance RFC6376 (Douglas Otis; 2013-05-30)", + "Appeal to the IESG in regards to RFC 6852 (PDF file) (JFC Morfin; 2013-04-05)", + "Appeal to the IESG concerning the approbation of the IDNA2008 document set (PDF file) (JFC Morfin; 2010-03-10)", + "Authentication-Results Header Field Appeal (Douglas Otis, David Rand; 2009-02-16) / Withdrawn by Submitter", + "Appeal to the IAB of IESG rejection of Appeal to Last Call draft-ietf-grow-anycast (Dean Anderson; 2008-11-14)", + "Appeal to the IESG Concerning the Way At Large Internet Lead Users Are Not Permitted To Adequately Contribute to the IETF Deliverables (JFC Morfin; 2008-09-10)", + "Appeal over suspension of posting rights for Todd Glassey (Todd Glassey; 2008-07-28)", + "Appeal against IESG blocking DISCUSS on draft-klensin-rfc2821bis (John C Klensin; 2008-06-13)", + "Appeal: Continued Abuse of Process by IPR-WG Chair (Dean Anderson; 2007-12-26)", + "Appeal to the IESG from Todd Glassey (Todd Glassey; 2007-11-26)", + "Appeal Against the Removal of the Co-Chairs of the GEOPRIV Working Group (PDF file) (Randall Gellens, Allison Mankin, and Andrew Newton; 2007-06-22)", + "Appeal concerning the WG-LTRU rechartering (JFC Morfin; 2006-10-24)", + "Appeal against decision within July 10 IESG appeal dismissal (JFC Morfin; 2006-09-09)", + "Appeal: Mandatory to implement HTTP authentication mechanism in the Atom Publishing Protocol (Robert Sayre; 2006-08-29)", + "Appeal Against IESG Decisions Regarding the draft-ietf-ltru-matching (PDF file) (JFC Morfin; 2006-08-16)", + "Amended Appeal Re: grow: Last Call: 'Operation of Anycast Services' to BCP (draft-ietf-grow-anycast) (Dean Anderson; 2006-06-14)", + "Appeal Against an IESG Decision Denying Me IANA Language Registration Process by way of PR-Action (PDF file) (JFC Morfin; 2006-05-17)", + "Appeal to the IESG of PR-Action against Dean Anderson (Dean Anderson; 2006-03-08)", + "Appeal to IESG against AD decision: one must clear the confusion opposing the RFC 3066 Bis consensus (JFC Morfin; 2006-02-20)", + "Appeal to the IESG of an IESG decision (JFC Morfin; 2006-02-17)", + "Appeal to the IESG in reference to the ietf-languages@alvestrand.no mailing list (JFC Morfin; 2006-02-07)", + "Appeal to the IESG against an IESG decision concerning RFC 3066 Bis Draft (JFC Morfin; 2006-01-14)", + "Appeal over a key change in a poor RFC 3066 bis example (JFC Morfin; 2005-10-19)", + "Additional appeal against publication of draft-lyon-senderid-* in regards to its recommended use of Resent- header fields in the way that is inconsistant with RFC2822(William Leibzon; 2005-08-29)", + "Appeal: Publication of draft-lyon-senderid-core-01 in conflict with referenced draft-schlitt-spf-classic-02 (Julian Mehnle; 2005-08-25)", + 'Appeal of decision to standardize "Mapping Between the Multimedia Messaging Service (MMS) and Internet Mail" (John C Klensin; 2005-06-10)', + "Appeal regarding IESG decision on the GROW WG (David Meyer; 2003-11-15)", + "Appeal: Official notice of appeal on suspension rights (Todd Glassey; 2003-08-06)", + "Appeal: AD response to Site-Local Appeal (Tony Hain; 2003-07-31)", + "Appeal against IESG decision for draft-chiba-radius-dynamic-authorization-05.txt (Glen Zorn; 2003-01-15)", + "Appeal Against moving draft-ietf-ipngwg-addr-arch-v3 to Draft Standard (Robert Elz; 2002-11-05)", + ] + date_re = re.compile(r"\d{4}-\d{2}-\d{2}") + dates = [ + datetime.datetime.strptime(date_re.search(t).group(), "%Y-%m-%d").date() + for t in titles + ] + + parts = [ + ["klensin-2023-08-15.txt", "response-to-klensin-2023-08-15.txt"], + [ + "hardie-frindell-2023-07-19.txt", + "response-to-hardie-frindell-2023-07-19.txt", + ], + ["mcsweeney-2020-07-08.txt", "response-to-mcsweeney-2020-07-08.pdf"], + ["gont-2020-04-22.txt", "response-to-gont-2020-06-02.txt"], + ["klensin-2020-02-04.txt", "response-to-klensin-2020-02-04.txt"], + ["klensin-2018-07-07.txt", "response-to-klensin-2018-07-07.txt"], + ["klensin-2017-11-29.txt", "response-to-klensin-2017-11-29.md"], + ["morfin-2015-03-11.pdf", "response-to-morfin-2015-03-11.md"], + ["conradi-2014-08-28.txt"], + ["masotta-2013-11-14.txt", "response-to-masotta-2013-11-14.md"], + ["baryun-2013-06-19.txt", "response-to-baryun-2013-06-19.md"], + ["otis-2013-05-30.txt", "response-to-otis-2013-05-30.md"], + ["morfin-2013-04-05.pdf", "response-to-morfin-2013-04-05.md"], + ["morfin-2010-03-10.pdf", "response-to-morfin-2010-03-10.txt"], + ["otis-2009-02-16.txt"], + ["anderson-2008-11-14.md", "response-to-anderson-2008-11-14.txt"], + ["morfin-2008-09-10.txt", "response-to-morfin-2008-09-10.txt"], + ["glassey-2008-07-28.txt", "response-to-glassey-2008-07-28.txt"], + ["klensin-2008-06-13.txt", "response-to-klensin-2008-06-13.txt"], + ["anderson-2007-12-26.txt", "response-to-anderson-2007-12-26.txt"], + ["glassey-2007-11-26.txt", "response-to-glassey-2007-11-26.txt"], + ["gellens-2007-06-22.pdf", "response-to-gellens-2007-06-22.txt"], + ["morfin-2006-10-24.txt", "response-to-morfin-2006-10-24.txt"], + ["morfin-2006-09-09.txt", "response-to-morfin-2006-09-09.txt"], + ["sayre-2006-08-29.txt", "response-to-sayre-2006-08-29.txt"], + [ + "morfin-2006-08-16.pdf", + "response-to-morfin-2006-08-17.txt", + "response-to-morfin-2006-08-17-part2.txt", + ], + ["anderson-2006-06-13.txt", "response-to-anderson-2006-06-14.txt"], + ["morfin-2006-05-17.pdf", "response-to-morfin-2006-05-17.txt"], + ["anderson-2006-03-08.md", "response-to-anderson-2006-03-08.txt"], + ["morfin-2006-02-20.txt", "response-to-morfin-2006-02-20.txt"], + ["morfin-2006-02-17.txt", "response-to-morfin-2006-02-17.txt"], + ["morfin-2006-02-07.txt", "response-to-morfin-2006-02-07.txt"], + ["morfin-2006-01-14.txt", "response-to-morfin-2006-01-14.txt"], + ["morfin-2005-10-19.txt", "response-to-morfin-2005-10-19.txt"], + ["leibzon-2005-08-29.txt", "response-to-leibzon-2005-08-29.txt"], + ["mehnle-2005-08-25.txt", "response-to-mehnle-2005-08-25.txt"], + ["klensin-2005-06-10.txt", "response-to-klensin-2005-06-10.txt"], + ["meyer-2003-11-15.txt", "response-to-meyer-2003-11-15.txt"], + ["glassey-2003-08-06.txt", "response-to-glassey-2003-08-06.txt"], + ["hain-2003-07-31.txt", "response-to-hain-2003-07-31.txt"], + ["zorn-2003-01-15.txt", "response-to-zorn-2003-01-15.txt"], + ["elz-2002-11-05.txt", "response-to-elz-2002-11-05.txt"], + ] + + assert len(titles) == len(dates) + assert len(titles) == len(parts) + + part_times = dict() + part_times["klensin-2023-08-15.txt"] = "2023-08-15 15:03:55 -0400" + part_times["response-to-klensin-2023-08-15.txt"] = "2023-08-24 18:54:13 +0300" + part_times["hardie-frindell-2023-07-19.txt"] = "2023-07-19 07:17:16PDT" + part_times["response-to-hardie-frindell-2023-07-19.txt"] = ( + "2023-08-15 11:58:26PDT" + ) + part_times["mcsweeney-2020-07-08.txt"] = "2020-07-08 14:45:00 -0400" + part_times["response-to-mcsweeney-2020-07-08.pdf"] = "2020-07-28 12:54:04 -0000" + part_times["gont-2020-04-22.txt"] = "2020-04-22 22:26:20 -0400" + part_times["response-to-gont-2020-06-02.txt"] = "2020-06-02 20:44:29 -0400" + part_times["klensin-2020-02-04.txt"] = "2020-02-04 13:54:46 -0500" + # part_times["response-to-klensin-2020-02-04.txt"]="2020-03-24 11:49:31EDT" + part_times["response-to-klensin-2020-02-04.txt"] = "2020-03-24 11:49:31 -0400" + part_times["klensin-2018-07-07.txt"] = "2018-07-07 12:40:43PDT" + # part_times["response-to-klensin-2018-07-07.txt"]="2018-08-16 10:46:45EDT" + part_times["response-to-klensin-2018-07-07.txt"] = "2018-08-16 10:46:45 -0400" + part_times["klensin-2017-11-29.txt"] = "2017-11-29 09:35:02 -0500" + part_times["response-to-klensin-2017-11-29.md"] = "2017-11-30 11:33:04 -0500" + part_times["morfin-2015-03-11.pdf"] = "2015-03-11 18:03:44 -0000" + part_times["response-to-morfin-2015-03-11.md"] = "2015-04-16 15:18:09 -0000" + part_times["conradi-2014-08-28.txt"] = "2014-08-28 22:28:06 +0300" + part_times["masotta-2013-11-14.txt"] = "2013-11-14 15:35:19 +0200" + part_times["response-to-masotta-2013-11-14.md"] = "2014-01-27 07:39:32 -0800" + part_times["baryun-2013-06-19.txt"] = "2013-06-19 06:29:51PDT" + part_times["response-to-baryun-2013-06-19.md"] = "2013-07-02 15:24:42 -0700" + part_times["otis-2013-05-30.txt"] = "2013-05-30 19:35:18 +0000" + part_times["response-to-otis-2013-05-30.md"] = "2013-06-27 11:56:48 -0700" + part_times["morfin-2013-04-05.pdf"] = "2013-04-05 17:31:19 -0700" + part_times["response-to-morfin-2013-04-05.md"] = "2013-04-17 08:17:29 -0700" + part_times["morfin-2010-03-10.pdf"] = "2010-03-10 21:40:58 +0100" + part_times["response-to-morfin-2010-03-10.txt"] = "2010-04-07 14:26:06 -0700" + part_times["otis-2009-02-16.txt"] = "2009-02-16 15:47:15 -0800" + part_times["anderson-2008-11-14.md"] = "2008-11-14 00:16:58 -0500" + part_times["response-to-anderson-2008-11-14.txt"] = "2008-12-15 11:00:02 -0800" + part_times["morfin-2008-09-10.txt"] = "2008-09-10 04:10:13 +0200" + part_times["response-to-morfin-2008-09-10.txt"] = "2008-09-28 10:00:01PDT" + part_times["glassey-2008-07-28.txt"] = "2008-07-28 08:34:52 -0700" + part_times["response-to-glassey-2008-07-28.txt"] = "2008-09-02 11:00:01PDT" + part_times["klensin-2008-06-13.txt"] = "2008-06-13 21:14:38 -0400" + part_times["response-to-klensin-2008-06-13.txt"] = "2008-07-07 10:00:01 PDT" + # part_times["anderson-2007-12-26.txt"]="2007-12-26 17:19:34EST" + part_times["anderson-2007-12-26.txt"] = "2007-12-26 17:19:34 -0500" + part_times["response-to-anderson-2007-12-26.txt"] = "2008-01-15 17:21:05 -0500" + part_times["glassey-2007-11-26.txt"] = "2007-11-26 08:13:22 -0800" + part_times["response-to-glassey-2007-11-26.txt"] = "2008-01-23 17:38:43 -0500" + part_times["gellens-2007-06-22.pdf"] = "2007-06-22 21:45:41 -0400" + part_times["response-to-gellens-2007-06-22.txt"] = "2007-09-20 14:01:27 -0400" + part_times["morfin-2006-10-24.txt"] = "2006-10-24 05:03:17 +0200" + part_times["response-to-morfin-2006-10-24.txt"] = "2006-11-07 12:56:02 -0500" + part_times["morfin-2006-09-09.txt"] = "2006-09-09 02:54:55 +0200" + part_times["response-to-morfin-2006-09-09.txt"] = "2006-09-15 12:56:31 -0400" + part_times["sayre-2006-08-29.txt"] = "2006-08-29 17:05:03 -0400" + part_times["response-to-sayre-2006-08-29.txt"] = "2006-10-16 13:07:18 -0400" + part_times["morfin-2006-08-16.pdf"] = "2006-08-16 18:28:19 -0400" + part_times["response-to-morfin-2006-08-17.txt"] = "2006-08-22 12:05:42 -0400" + part_times["response-to-morfin-2006-08-17-part2.txt"] = ( + "2006-11-07 13:00:58 -0500" + ) + # part_times["anderson-2006-06-13.txt"]="2006-06-13 21:51:18EDT" + part_times["anderson-2006-06-13.txt"] = "2006-06-13 21:51:18 -0400" + part_times["response-to-anderson-2006-06-14.txt"] = "2006-07-10 14:31:08 -0400" + part_times["morfin-2006-05-17.pdf"] = "2006-05-17 06:46:18 +0200" + part_times["response-to-morfin-2006-05-17.txt"] = "2006-07-10 14:18:10 -0400" + part_times["anderson-2006-03-08.md"] = "2006-03-08 09:42:44 +0100" + part_times["response-to-anderson-2006-03-08.txt"] = "2006-03-20 14:55:38 -0500" + part_times["morfin-2006-02-20.txt"] = "2006-02-20 19:18:24 +0100" + part_times["response-to-morfin-2006-02-20.txt"] = "2006-03-06 13:08:39 -0500" + part_times["morfin-2006-02-17.txt"] = "2006-02-17 18:59:38 +0100" + part_times["response-to-morfin-2006-02-17.txt"] = "2006-07-10 14:05:15 -0400" + part_times["morfin-2006-02-07.txt"] = "2006-02-07 19:38:57 -0500" + part_times["response-to-morfin-2006-02-07.txt"] = "2006-02-21 19:09:26 -0500" + part_times["morfin-2006-01-14.txt"] = "2006-01-14 15:05:24 +0100" + part_times["response-to-morfin-2006-01-14.txt"] = "2006-02-21 12:23:38 -0500" + part_times["morfin-2005-10-19.txt"] = "2005-10-19 17:12:11 +0200" + part_times["response-to-morfin-2005-10-19.txt"] = "2005-11-15 11:42:30 -0500" + part_times["leibzon-2005-08-29.txt"] = "2005-08-29 08:28:52PDT" + part_times["response-to-leibzon-2005-08-29.txt"] = "2005-12-08 14:04:47 -0500" + part_times["mehnle-2005-08-25.txt"] = "2005-08-25 00:45:26 +0200" + part_times["response-to-mehnle-2005-08-25.txt"] = "2005-12-08 13:37:38 -0500" + part_times["klensin-2005-06-10.txt"] = "2005-06-10 14:49:17 -0400" + part_times["response-to-klensin-2005-06-10.txt"] = "2005-07-22 18:14:06 -0400" + part_times["meyer-2003-11-15.txt"] = "2003-11-15 09:47:11 -0800" + part_times["response-to-meyer-2003-11-15.txt"] = "2003-11-25 10:56:06 -0500" + part_times["glassey-2003-08-06.txt"] = "2003-08-06 02:14:24 +0000" + part_times["response-to-glassey-2003-08-06.txt"] = "2003-09-24 09:54:51 -0400" + part_times["hain-2003-07-31.txt"] = "2003-07-31 16:44:19 -0700" + part_times["response-to-hain-2003-07-31.txt"] = "2003-09-30 14:44:30 -0400" + part_times["zorn-2003-01-15.txt"] = "2003-01-15 01:22:28 -0800" + part_times["elz-2002-11-05.txt"] = "2002-11-05 10:51:13 +0700" + # No time could be found for this one: + part_times["response-to-zorn-2003-01-15.txt"] = "2003-02-08" + # This one was issued sometime between 2002-12-27 (when IESG minutes note that the + # appeal response was approved) and 2003-01-04 (when the appeal was escalated to + # the IAB) - we're using the earlier end of the window + part_times["response-to-elz-2002-11-05.txt"] = "2002-12-27" + for name in part_times: + part_times[name] = dateutil.parser.parse(part_times[name]).astimezone( + datetime.timezone.utc + ) + + redirects = [] + for index, title in enumerate(titles): + # IESG is group 2 + appeal = Appeal.objects.create( + name=titles[index], date=dates[index], group_id=2 + ) + for part in parts[index]: + if part.endswith(".pdf"): + content_type = "application/pdf" + else: + content_type = "text/markdown;charset=utf-8" + if part.endswith(".md"): + source_path = Path(tmpdir) / "iesg_appeals" / part + else: + source_path = Path(old_appeals_root) / part + with source_path.open("rb") as source_file: + bits = source_file.read() + if part == "morfin-2008-09-10.txt": + bits = bits.decode("macintosh") + bits = bits.replace("\r", "\n") + bits = bits.encode("utf8") + elif part in ["morfin-2006-02-07.txt", "morfin-2006-01-14.txt"]: + bits = bits.decode("windows-1252").encode("utf8") + artifact_type_id = ( + "response" if part.startswith("response") else "appeal" + ) + artifact = AppealArtifact.objects.create( + appeal=appeal, + artifact_type_id=artifact_type_id, + date=part_times[part].date(), + content_type=content_type, + bits=bits, + ) + redirects.append( + [ + f'www6.ietf.org/iesg/appeal/{part.replace(".md", ".html") if part.endswith(".md") else part}', + f"https://datatracker.ietf.org/group/iesg/appeals/artifact/{artifact.pk}", + 302, + ] + ) + + shutil.rmtree(tmpdir) + with open("iesg_appeal_redirects.csv", "w", newline="") as f: + csvwriter = csv.writer(f) + for row in redirects: + csvwriter.writerow(row) diff --git a/ietf/group/management/commands/import_iesg_statements.py b/ietf/group/management/commands/import_iesg_statements.py new file mode 100644 index 0000000000..93fdcec161 --- /dev/null +++ b/ietf/group/management/commands/import_iesg_statements.py @@ -0,0 +1,274 @@ +# Copyright The IETF Trust 2024, All Rights Reserved + +import debug # pyflakes:ignore + +import csv +import datetime +import os +import shutil +import subprocess +import tempfile + +from collections import namedtuple, Counter +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand + +from ietf.doc.models import Document, DocEvent, State +from ietf.utils.text import xslugify + + +class Command(BaseCommand): + help = "Performs a one-time import of IESG statements" + + def handle(self, *args, **options): + if Document.objects.filter(type="statement", group__acronym="iesg").exists(): + self.stdout.write("IESG statement documents already exist - exiting") + exit(-1) + tmpdir = tempfile.mkdtemp() + process = subprocess.Popen( + ["git", "clone", "https://github.com/kesara/iesg-scraper.git", tmpdir], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + sub_stdout, sub_stderr = process.communicate() + if not Path(tmpdir).joinpath("iesg_statements", "2000-08-29-0.md").exists(): + self.stdout.write( + "Git clone of the iesg-scraper directory did not go as expected" + ) + self.stdout.write("stdout:", sub_stdout) + self.stdout.write("stderr:", sub_stderr) + self.stdout.write(f"Clean up {tmpdir} manually") + exit(-1) + + redirects = [] + for item in self.get_work_items(): + replaced = item.title.endswith( + " SUPERSEDED" + ) or item.doc_time.date() == datetime.date(2007, 7, 30) + title = item.title + if title.endswith(" - SUPERSEDED"): + title = title[: -len(" - SUPERSEDED")] + name = f"statement-iesg-{xslugify(title)}-{item.doc_time:%Y%m%d}" + dest_filename = f"{name}-00.md" + # Create Document + doc = Document.objects.create( + name=name, + type_id="statement", + title=title, + group_id=2, # The IESG group + rev="00", + uploaded_filename=dest_filename, + ) + doc.set_state( + State.objects.get( + type_id="statement", + slug="replaced" if replaced else "active", + ) + ) + e1 = DocEvent.objects.create( + time=item.doc_time, + type="published_statement", + doc=doc, + rev="00", + by_id=1, # (System) + desc="Statement published (note: The exact time of day is inaccurate - the actual time of day is not known)", + ) + e2 = DocEvent.objects.create( + type="added_comment", + doc=doc, + rev="00", + by_id=1, # (System) + desc="Statement moved into datatracker from www.ietf.org", + ) + doc.save_with_history([e1, e2]) + + # Put file in place + source = Path(tmpdir).joinpath("iesg_statements", item.source_filename) + dest = Path(settings.DOCUMENT_PATH_PATTERN.format(doc=doc)).joinpath( + dest_filename + ) + if dest.exists(): + self.stdout.write( + f"WARNING: {dest} already exists - not overwriting it." + ) + else: + os.makedirs(dest.parent, exist_ok=True) + shutil.copy(source, dest) + + redirects.append( + [ + f"www.ietf.org/about/groups/iesg/statements/{item.slug}", + f"https://datatracker.ietf.org/doc/{name}", + 302, + ] + ) + + shutil.rmtree(tmpdir) + with open("iesg_statement_redirects.csv", "w", newline="") as f: + csvwriter = csv.writer(f) + for row in redirects: + csvwriter.writerow(row) + + def get_work_items(self): + Item = namedtuple("Item", "doc_time source_filename title slug") + items = [] + dressed_rows = " ".join( + self.cut_paste_from_www().expandtabs(1).split(" ") + ).split("\n") + old_slugs = self.get_old_slugs() + # Rube-Goldberg-esque dance to deal with conflicting directions of the scrape and + # what order we want the result to sort to + dressed_rows.reverse() + old_slugs.reverse() + total_times_date_seen = Counter([row.split(" ")[0] for row in dressed_rows]) + count_date_seen_so_far = Counter() + for row, slug in zip(dressed_rows, old_slugs): + date_part = row.split(" ")[0] + title_part = row[len(date_part) + 1 :] + datetime_args = list(map(int, date_part.replace("-0", "-").split("-"))) + # Use the minutes in timestamps to preserve order of statements + # on the same day as they currently appear at www.ietf.org + datetime_args.extend([12, count_date_seen_so_far[date_part]]) + count_date_seen_so_far[date_part] += 1 + doc_time = datetime.datetime(*datetime_args, tzinfo=datetime.timezone.utc) + items.append( + Item( + doc_time, + f"{date_part}-{total_times_date_seen[date_part] - count_date_seen_so_far[date_part]}.md", + title_part, + slug, + ) + ) + return items + + def cut_paste_from_www(self): + return """2023-08-24 Support Documents in IETF Working Groups +2023-08-14 Guidance on In-Person and Online Interim Meetings +2023-05-01 IESG Statement on EtherTypes +2023-03-15 Second Report on the RFC 8989 Experiment +2023-01-27 Guidance on In-Person and Online Interim Meetings - SUPERSEDED +2022-10-31 Statement on Restricting Access to IETF IT Systems +2022-01-21 Handling Ballot Positions +2021-09-01 Report on the RFC 8989 experiment +2021-07-21 IESG Statement on Allocation of Email Addresses in the ietf.org Domain +2021-05-11 IESG Statement on Inclusive Language +2021-05-10 IESG Statement on Internet-Draft Authorship +2021-05-07 IESG Processing of RFC Errata for the IETF Stream +2021-04-16 Last Call Guidance to the Community +2020-07-23 IESG Statement On Oppressive or Exclusionary Language +2020-05-01 Guidance on Face-to-Face and Virtual Interim Meetings - SUPERSEDED +2018-03-16 IETF Meeting Photography Policy +2018-01-11 Guidance on Face-to-Face and Virtual Interim Meetings - SUPERSEDED +2017-02-09 License File for Open Source Repositories +2016-11-13 Support Documents in IETF Working Groups - SUPERSEDED +2016-02-05 Guidance on Face-to-Face and Virtual Interim Meetings - SUPERSEDED +2016-01-11 Guidance on Face-to-Face and Virtual Interim Meetings - SUPERSEDED +2015-08-20 IESG Statement on Maximizing Encrypted Access To IETF Information +2015-06-11 IESG Statement on Internet-Draft Authorship - SUPERSEDED +2014-07-20 IESG Statement on Designating RFCs as Historic +2014-05-07 DISCUSS Criteria in IESG Review +2014-03-02 Writable MIB Module IESG Statement +2013-11-03 IETF Anti-Harassment Policy +2012-10-25 IESG Statement on Ethertypes - SUPERSEDED +2012-10-25 IESG Statement on Removal of an Internet-Draft from the IETF Web Site +2011-10-20 IESG Statement on Designating RFCs as Historic - SUPERSEDED +2011-06-27 IESG Statement on Designating RFCs as Historic - SUPERSEDED +2011-06-13 IESG Statement on IESG Processing of RFC Errata concerning RFC Metadata +2010-10-11 IESG Statement on Document Shepherds +2010-05-24 IESG Statement on the Usage of Assignable Codepoints, Addresses and Names in Specification Examples +2010-05-24 IESG Statement on NomCom Eligibility and Day Passes +2009-09-08 IESG Statement on Copyright +2009-01-20 IESG Statement on Proposed Status for IETF Documents Reserving Resources for Example Purposes +2008-09-02 Guidance on Interim Meetings, Conference Calls and Jabber Sessions - SUPERSEDED +2008-07-30 IESG Processing of RFC Errata for the IETF Stream +2008-04-14 IESG Statement on Spam Control on IETF Mailing Lists +2008-03-03 IESG Statement on Registration Requests for URIs Containing Telephone Numbers +2008-02-27 IESG Statement on RFC3406 and URN Namespaces Registry Review +2008-01-23 Advice for WG Chairs Dealing with Off-Topic Postings +2007-10-04 On Appeals of IESG and Area Director Actions and Decisions +2007-07-05 Experimental Specification of New Congestion Control Algorithms +2007-03-20 Guidance on Area Director Sponsoring of Documents +2007-01-15 Last Call Guidance to the Community - SUPERSEDED +2006-04-19 IESG Statement: Normative and Informative References +2006-02-17 IESG Statement on Disruptive Posting +2006-01-09 Guidance for Spam Control on IETF Mailing Lists - SUPERSEDED +2006-01-05 IESG Statement on AUTH48 State +2005-05-12 Syntax for Format Definitions +2003-02-11 IESG Statement on IDN +2002-11-27 Copyright Statement in MIB and PIB Modules +2002-03-13 Guidance for Spam Control on IETF Mailing Lists - SUPERSEDED +2001-12-21 On Design Teams +2001-10-01 Guidelines for the Use of Formal Languages in IETF Specifications +2001-03-21 Establishment of Temporary Sub-IP Area +2000-12-06 Plans to Organize "Sub-IP" Technologies in the IETF +2000-11-20 A New IETF Work Area +2000-08-29 Guidance on Interim IETF Working Group Meetings and Conference Calls - SUPERSEDED +2000-08-29 IESG Guidance on the Moderation of IETF Working Group Mailing Lists""" + + def get_old_slugs(self): + return [ + "support-documents", + "interim-meetings-guidance", + "ethertypes", + "second-report-on-the-rfc-8989-experiment", + "interim-meetings-guidance-2023-01-27", + "statement-on-restricting-access", + "handling-ballot-positions", + "report-on-rfc8989-experiment", + "email-addresses-ietf-domain", + "on-inclusive-language", + "internet-draft-authorship", + "processing-errata-ietf-stream", + "last-call-guidance", + "statement-on-oppressive-exclusionary-language", + "interim-meetings-guidance-2020-05-01", + "meeting-photography-policy", + "interim-meetings-guidance-2018-01-11", + "open-source-repositories-license", + "support-documents-2016-11-13", + "interim-meetings-guidance-2016-02-05", + "interim-meetings-guidance-2016-01-11", + "maximizing-encrypted-access", + "internet-draft-authorship-2015-06-11", + "designating-rfcs-historic", + "iesg-discuss-criteria", + "writable-mib-module", + "anti-harassment-policy", + "ethertypes-2012-10-25", + "internet-draft-removal", + "designating-rfcs-historic-2011-10-20", + "designating-rfcs-historic-2011-06-27", + "rfc-metadata-errata", + "document-shepherds", + "assignable-codepoints-addresses-names", + "nomcom-eligibility-day-passes", + "copyright-2009-09-08", + "reserving-resources-examples", + "interim-meetings-guidance-2008-09-02", + "processing-rfc-errata", + "spam-control-2008-04-14", + "registration-requests-uris", + "urn-namespaces-registry", + "off-topic-postings", + "appeals-actions-decisions", + "experimental-congestion-control", + "area-director-sponsoring-documents", + "last-call-guidance-2007-01-15", + "normative-informative-references", + "disruptive-posting", + "spam-control-2006-01-09", + "auth48", + "syntax-format-definitions", + "idn", + "copyright-2002-11-27", + "spam-control-2002-03-13", + "design-teams", + "formal-languages-use", + "sub-ip-area-2001-03-21", + "sub-ip-area-2000-11-20", + "sub-ip-area-2000-12-06", + "interim-meetings-guidance-2000-08-29", + "mailing-lists-moderation", + ] diff --git a/ietf/group/migrations/0004_modern_list_archive.py b/ietf/group/migrations/0004_modern_list_archive.py new file mode 100644 index 0000000000..91c2fd23e2 --- /dev/null +++ b/ietf/group/migrations/0004_modern_list_archive.py @@ -0,0 +1,24 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.conf import settings +from django.db import migrations +from django.db.models import Value +from django.db.models.functions import Replace + + +def forward(apps, schema_editor): + Group = apps.get_model("group", "Group") + old_pattern = f"{settings.MAILING_LIST_ARCHIVE_URL}/arch/search/?email_list=" + new_pattern = f"{settings.MAILING_LIST_ARCHIVE_URL}/arch/browse/" + + Group.objects.filter(list_archive__startswith=old_pattern).update( + list_archive=Replace("list_archive", Value(old_pattern), Value(new_pattern)) + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("group", "0003_iabworkshops"), + ] + + operations = [migrations.RunPython(forward)] diff --git a/ietf/group/milestones.py b/ietf/group/milestones.py index 039fdb44ce..52f2eaebee 100644 --- a/ietf/group/milestones.py +++ b/ietf/group/milestones.py @@ -399,7 +399,7 @@ def save_milestone_form(f): can_change_uses_milestone_dates=can_change_uses_milestone_dates)) @login_required -def reset_charter_milestones(request, group_type, acronym): +def reset_charter_milestones(request, acronym, group_type=None): """Reset charter milestones to the currently in-use milestones.""" group = get_group_or_404(acronym, group_type) if not group.features.has_milestones: diff --git a/ietf/group/tests.py b/ietf/group/tests.py index b11ed8e5fb..8ae6a626b2 100644 --- a/ietf/group/tests.py +++ b/ietf/group/tests.py @@ -1,14 +1,12 @@ # Copyright The IETF Trust 2013-2020, All Rights Reserved # -*- coding: utf-8 -*- -import io import os import datetime import json from tempfile import NamedTemporaryFile -from django.core.management import call_command from django.conf import settings from django.urls import reverse as urlreverse from django.db.models import Q @@ -20,7 +18,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 @@ -146,7 +144,13 @@ def tearDown(self): os.unlink(self.doc_virtual_file.name) super().tearDown() - def testManagementCommand(self): + 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') @@ -163,12 +167,11 @@ 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') - done = role2.group - done_ad = role2.person + # 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') @@ -176,77 +179,28 @@ def testManagementCommand(self): 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()) - individual = PersonFactory() - - args = [ ] - kwargs = { } - out = io.StringIO() - call_command("generate_group_aliases", *args, **kwargs, stdout=out, stderr=out) - self.assertFalse(out.getvalue()) - - with open(settings.GROUP_ALIASES_PATH) as afile: - acontent = afile.read() - self.assertTrue('xfilter-' + area.acronym + '-ads' in acontent) - self.assertTrue('xfilter-' + area.acronym + '-chairs' in acontent) - self.assertTrue('xfilter-' + mars.acronym + '-ads' in acontent) - self.assertTrue('xfilter-' + mars.acronym + '-chairs' in acontent) - self.assertTrue('xfilter-' + ames.acronym + '-ads' in acontent) - self.assertTrue('xfilter-' + ames.acronym + '-chairs' in acontent) - self.assertTrue(all([x in acontent for x in [ - 'xfilter-' + area.acronym + '-ads', - 'xfilter-' + area.acronym + '-chairs', - 'xfilter-' + mars.acronym + '-ads', - 'xfilter-' + mars.acronym + '-chairs', - 'xfilter-' + ames.acronym + '-ads', - 'xfilter-' + ames.acronym + '-chairs', - 'xfilter-' + recent.acronym + '-ads', - 'xfilter-' + recent.acronym + '-chairs', - ]])) - self.assertFalse(all([x in acontent for x in [ - 'xfilter-' + done.acronym + '-ads', - 'xfilter-' + done.acronym + '-chairs', - 'xfilter-' + wayold.acronym + '-ads', - 'xfilter-' + wayold.acronym + '-chairs', - ]])) - - with open(settings.GROUP_VIRTUAL_PATH) as vfile: - vcontent = vfile.read() - self.assertTrue(all([x in vcontent for x in [ - ad.email_address(), - marschair.email_address(), - marssecr.email_address(), - ameschair.email_address(), - recentchair.email_address(), - testrgchair.email_address(), - testragchair.email_address(), - ]])) - self.assertFalse(all([x in vcontent for x in [ - done_ad.email_address(), - wayoldchair.email_address(), - individual.email_address(), - ]])) - self.assertTrue(all([x in vcontent for x in [ - 'xfilter-' + area.acronym + '-ads', - 'xfilter-' + area.acronym + '-chairs', - 'xfilter-' + mars.acronym + '-ads', - 'xfilter-' + mars.acronym + '-chairs', - 'xfilter-' + ames.acronym + '-ads', - 'xfilter-' + ames.acronym + '-chairs', - 'xfilter-' + recent.acronym + '-ads', - 'xfilter-' + recent.acronym + '-chairs', - 'xfilter-' + testrg.acronym + '-chairs', - 'xfilter-' + testrag.acronym + '-chairs', - testrg.acronym + '-chairs@ietf.org', - testrg.acronym + '-chairs@irtf.org', - testrag.acronym + '-chairs@ietf.org', - testrag.acronym + '-chairs@irtf.org', - ]])) - self.assertFalse(all([x in vcontent for x in [ - 'xfilter-' + done.acronym + '-ads', - 'xfilter-' + done.acronym + '-chairs', - 'xfilter-' + wayold.acronym + '-ads', - 'xfilter-' + wayold.acronym + '-chairs', - ]])) + + 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/tests_info.py b/ietf/group/tests_info.py index 4c353f1108..6ecac7d347 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2023, All Rights Reserved +# Copyright The IETF Trust 2009-2024, All Rights Reserved # -*- coding: utf-8 -*- @@ -2044,8 +2044,17 @@ def test_admin_acronym_validation(self): self.assertTrue(form.is_valid()) form = AdminGroupForm({'acronym':'shouldfail-','name':'should fail','type':'wg','state':'active','used_roles':'[]','time':now}) self.assertIn('acronym',form.errors) + form = AdminGroupForm({'acronym':'shouldfail-','name':'should fail','type':'sdo','state':'active','used_roles':'[]','time':now}) + self.assertIn('acronym',form.errors) form = AdminGroupForm({'acronym':'-shouldfail','name':'should fail','type':'wg','state':'active','used_roles':'[]','time':now}) self.assertIn('acronym',form.errors) + form = AdminGroupForm({'acronym':'-shouldfail','name':'should fail','type':'sdo','state':'active','used_roles':'[]','time':now}) + self.assertIn('acronym',form.errors) + # SDO groups (and only SDO groups) can have a leading number + form = AdminGroupForm({'acronym':'3gpp-should-pass','name':'should pass','type':'sdo','state':'active','used_roles':'[]','time':now}) + self.assertTrue(form.is_valid()) + form = AdminGroupForm({'acronym':'123shouldfail','name':'should fail','type':'wg','state':'active','used_roles':'[]','time':now}) + self.assertIn('acronym',form.errors) wg = GroupFactory(acronym='bad-idea', type_id='wg') # There are some existing wg and programs with hyphens in their acronyms. form = AdminGroupForm({'acronym':wg.acronym,'name':wg.name,'type':wg.type_id,'state':wg.state_id,'used_roles':str(wg.used_roles),'time':now},instance=wg) 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/group/views.py b/ietf/group/views.py index 698963678a..636871d901 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -43,12 +43,14 @@ import json from collections import OrderedDict, defaultdict +import types from simple_history.utils import update_change_reason from django import forms from django.conf import settings from django.contrib.auth.decorators import login_required -from django.db.models import Q, Count, OuterRef, Subquery +from django.db.models import Count, F, OuterRef, Prefetch, Q, Subquery, TextField, Value +from django.db.models.functions import Coalesce from django.http import HttpResponse, HttpResponseRedirect, Http404, JsonResponse from django.shortcuts import render, redirect, get_object_or_404 from django.template.loader import render_to_string @@ -83,6 +85,7 @@ from ietf.ietfauth.utils import has_role, is_authorized_in_group from ietf.mailtrigger.utils import gather_relevant_expansions from ietf.meeting.helpers import get_meeting +from ietf.meeting.models import ImportantDate, SchedTimeSessAssignment, SchedulingEvent from ietf.meeting.utils import group_sessions from ietf.name.models import GroupTypeName, StreamName from ietf.person.models import Email, Person @@ -116,6 +119,7 @@ from ietf.mailtrigger.utils import gather_address_lists from ietf.mailtrigger.models import Recipient from ietf.settings import MAILING_LIST_INFO_URL +from ietf.utils.decorators import ignore_view_kwargs from ietf.utils.response import permission_denied from ietf.utils.text import strip_suffix from ietf.utils import markdown @@ -834,21 +838,70 @@ def meetings(request, acronym, group_type=None): four_years_ago = timezone.now() - datetime.timedelta(days=4 * 365) - sessions = ( - group.session_set.with_current_status() - .filter( - meeting__date__gt=four_years_ago - if group.acronym != "iab" - else datetime.date(1970, 1, 1), - type__in=["regular", "plenary", "other"], - ) - .filter( - current_status__in=["sched", "schedw", "appr", "canceled"], + stsas = SchedTimeSessAssignment.objects.filter( + session__type__in=["regular", "plenary", "other"], + session__group=group) + if group.acronym not in ["iab", "iesg"]: + stsas = stsas.filter(session__meeting__date__gt=four_years_ago) + stsas = stsas.annotate(sessionstatus=Coalesce( + Subquery( + SchedulingEvent.objects.filter( + session=OuterRef("session__pk") + ).order_by( + '-time', '-id' + ).values('status')[:1]), + Value(''), + output_field=TextField()) + ).filter( + sessionstatus__in=["sched", "schedw", "appr", "canceled"], + session__meeting__schedule=F("schedule") + ).distinct().select_related( + "session", "session__group", "session__group__parent", "session__meeting__type", "timeslot" + ).prefetch_related( + "session__materials", + "session__materials__states", + Prefetch("session__materials", + queryset=Document.objects.exclude(states__type=F("type"), states__slug='deleted').order_by('presentations__order').prefetch_related('states'), + to_attr="prefetched_active_materials" + ), + ) + + stsas = list(stsas) + + for stsa in stsas: + stsa.session._otsa = stsa + stsa.session.official_timeslotassignment = types.MethodType(lambda self:self._otsa, stsa.session) + stsa.session.current_status = stsa.sessionstatus + + sessions = sorted( + set([stsa.session for stsa in stsas]), + key=lambda x: ( + x._otsa.timeslot.time, + x._otsa.timeslot.type_id, + x._otsa.session.group.parent.name if x._otsa.session.group.parent else None, + x._otsa.session.name ) ) - sessions = list(sessions) + + meeting_seen = None + for s in sessions: + if s.meeting != meeting_seen: + meeting_seen = s.meeting + order = 1 + s._oim = order + s.order_in_meeting = types.MethodType(lambda self:self._oim, s) + order += 1 + + + revsub_dates_by_meeting = dict(ImportantDate.objects.filter(name_id="revsub", meeting__session__in=sessions).distinct().values_list("meeting_id","date")) + for s in sessions: s.order_number = s.order_in_meeting() + if s.meeting.pk in revsub_dates_by_meeting: + cutoff_date = revsub_dates_by_meeting[s.meeting.pk] + else: + cutoff_date = s.meeting.date + datetime.timedelta(days=s.meeting.submission_correction_day_offset) + s.cached_is_cutoff = date_today(datetime.timezone.utc) > cutoff_date future, in_progress, recent, past = group_sessions(sessions) @@ -856,7 +909,7 @@ def meetings(request, acronym, group_type=None): can_always_edit = has_role(request.user, ["Secretariat", "Area Director"]) far_past = [] - if group.acronym == "iab": + if group.acronym in ["iab", "iesg"]: recent_past = [] for s in past: if s.time >= four_years_ago: @@ -1346,16 +1399,36 @@ def stream_edit(request, acronym): ) -@cache_control(public=True, max_age=30*60) +@cache_control(public=True, max_age=30 * 60) @cache_page(30 * 60) def group_menu_data(request): - groups = Group.objects.filter(state="active", parent__state="active").filter(Q(type__features__acts_like_wg=True)|Q(type_id__in=['program','iabasg','iabworkshop'])|Q(parent__acronym='ietfadminllc')|Q(parent__acronym='rfceditor')).order_by("-type_id","acronym") + groups = ( + Group.objects.filter(state="active", parent__state="active") + .filter( + Q(type__features__acts_like_wg=True) + | Q(type_id__in=["program", "iabasg", "iabworkshop"]) + | Q(parent__acronym="ietfadminllc") + | Q(parent__acronym="rfceditor") + ) + .order_by("-type_id", "acronym") + .select_related("type") + ) groups_by_parent = defaultdict(list) for g in groups: - url = urlreverse("ietf.group.views.group_home", kwargs={ 'group_type': g.type_id, 'acronym': g.acronym }) -# groups_by_parent[g.parent_id].append({ 'acronym': g.acronym, 'name': escape(g.name), 'url': url }) - groups_by_parent[g.parent_id].append({ 'acronym': g.acronym, 'name': escape(g.name), 'type': escape(g.type.verbose_name or g.type.name), 'url': url }) + url = urlreverse( + "ietf.group.views.group_home", + kwargs={"group_type": g.type_id, "acronym": g.acronym}, + ) + # groups_by_parent[g.parent_id].append({ 'acronym': g.acronym, 'name': escape(g.name), 'url': url }) + groups_by_parent[g.parent_id].append( + { + "acronym": g.acronym, + "name": escape(g.name), + "type": escape(g.type.verbose_name or g.type.name), + "url": url, + } + ) iab = Group.objects.get(acronym="iab") groups_by_parent[iab.pk].insert( @@ -1364,12 +1437,15 @@ def group_menu_data(request): "acronym": iab.acronym, "name": iab.name, "type": "Top Level Group", - "url": urlreverse("ietf.group.views.group_home", kwargs={"acronym": iab.acronym}) - } + "url": urlreverse( + "ietf.group.views.group_home", kwargs={"acronym": iab.acronym} + ), + }, ) return JsonResponse(groups_by_parent) + @cache_control(public=True, max_age=30 * 60) @cache_page(30 * 60) def group_stats_data(request, years="3", only_active=True): @@ -2115,14 +2191,24 @@ def statements(request, acronym, group_type=None): if not acronym in ["iab", "iesg"]: raise Http404 group = get_group_or_404(acronym, group_type) - statements = group.document_set.filter(type_id="statement").annotate( - published=Subquery( - DocEvent.objects.filter( - doc=OuterRef("pk"), - type="published_statement" - ).order_by("-time").values("time")[:1] + statements = ( + group.document_set.filter(type_id="statement") + .annotate( + published=Subquery( + DocEvent.objects.filter(doc=OuterRef("pk"), type="published_statement") + .order_by("-time") + .values("time")[:1] + ) ) - ).order_by("-published") + .annotate( + status=Subquery( + Document.states.through.objects.filter( + document_id=OuterRef("pk"), state__type="statement" + ).values_list("state__slug", flat=True)[:1] + ) + ) + .order_by("-published") + ) return render( request, "group/statements.html", @@ -2158,7 +2244,8 @@ def appeals(request, acronym, group_type=None): ), ) -def appeal_artifact(request, acronym, artifact_id, group_type=None): +@ignore_view_kwargs("group_type") +def appeal_artifact(request, acronym, artifact_id): artifact = get_object_or_404(AppealArtifact, pk=artifact_id) if artifact.is_markdown(): artifact_html = markdown.markdown(artifact.bits.tobytes().decode("utf-8")) @@ -2177,7 +2264,8 @@ def appeal_artifact(request, acronym, artifact_id, group_type=None): ) @role_required("Secretariat") -def appeal_artifact_markdown(request, acronym, artifact_id, group_type=None): +@ignore_view_kwargs("group_type") +def appeal_artifact_markdown(request, acronym, artifact_id): artifact = get_object_or_404(AppealArtifact, pk=artifact_id) if artifact.is_markdown(): return HttpResponse(artifact.bits, content_type=artifact.content_type) diff --git a/ietf/help/tests_views.py b/ietf/help/tests_views.py deleted file mode 100644 index ee80dad865..0000000000 --- a/ietf/help/tests_views.py +++ /dev/null @@ -1,21 +0,0 @@ -from pyquery import PyQuery - -from django.urls import reverse - -import debug # pyflakes:ignore - -from ietf.utils.test_utils import TestCase -from ietf.doc.models import StateType - -class HelpPageTests(TestCase): - - def test_state_index(self): - url = reverse('ietf.help.views.state_index') - r = self.client.get(url) - q = PyQuery(r.content) - content = [ e.text for e in q('#content table td a ') ] - names = StateType.objects.values_list('slug', flat=True) - # The following doesn't cover all doc types, only a selection - for name in names: - if not '-' in name: - self.assertIn(name, content) diff --git a/ietf/help/urls.py b/ietf/help/urls.py index f1cc625fa7..90ce7e12e9 100644 --- a/ietf/help/urls.py +++ b/ietf/help/urls.py @@ -2,10 +2,10 @@ from ietf.help import views from ietf.utils.urls import url +from django.views.generic import RedirectView urlpatterns = [ url(r'^state/(?P[-\w]+)/(?P[-\w]+)/?$', views.state), url(r'^state/(?P[-\w]+)/?$', views.state), - url(r'^state/?$', views.state_index), + url(r'^state/?$', RedirectView.as_view(url='/doc/help/state/', permanent=True)), ] - diff --git a/ietf/help/views.py b/ietf/help/views.py index 6b10f9f6c3..493bf0dcf1 100644 --- a/ietf/help/views.py +++ b/ietf/help/views.py @@ -1,23 +1,11 @@ # Copyright The IETF Trust 2007, All Rights Reserved -from django.shortcuts import get_object_or_404, render - import debug # pyflakes:ignore -from ietf.doc.models import State, StateType from ietf.name.models import StreamName +from django.shortcuts import redirect -def state_index(request): - types = StateType.objects.all() - names = [ type.slug for type in types ] - for type in types: - if "-" in type.slug and type.slug.split('-',1)[0] in names: - type.stategroups = None - else: - groups = StateType.objects.filter(slug__startswith=type.slug) - type.stategroups = [ g.slug[len(type.slug)+1:] for g in groups if not g == type ] or "" - - return render(request, 'help/state_index.html', {"types": types}) +# This is just a redirect to the new URL under /doc; can probably go away eventually. def state(request, doc, type=None): if type: @@ -25,6 +13,5 @@ def state(request, doc, type=None): if type in streams: type = "stream-%s" % type slug = "%s-%s" % (doc,type) if type else doc - statetype = get_object_or_404(StateType, slug=slug) - states = State.objects.filter(used=True, type=statetype).order_by('order') - return render(request, 'help/states.html', {"doc": doc, "type": statetype, "states":states} ) + return redirect('/doc/help/state/%s' % slug, permanent = True) + \ No newline at end of file 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/ipr/models.py b/ietf/ipr/models.py index a3c917747e..693f19abe9 100644 --- a/ietf/ipr/models.py +++ b/ietf/ipr/models.py @@ -16,11 +16,11 @@ class IprDisclosureBase(models.Model): by = ForeignKey(Person) # who was logged in, or System if nobody was logged in compliant = models.BooleanField("Complies to RFC3979", default=True) - docs = models.ManyToManyField(Document, through='IprDocRel') + docs = models.ManyToManyField(Document, through='ipr.IprDocRel') holder_legal_name = models.CharField(max_length=255) notes = models.TextField("Additional notes", blank=True) other_designations = models.CharField("Designations for other contributions", blank=True, max_length=255) - rel = models.ManyToManyField('self', through='RelatedIpr', symmetrical=False) + rel = models.ManyToManyField('self', through='ipr.RelatedIpr', symmetrical=False) state = ForeignKey(IprDisclosureStateName) submitter_name = models.CharField(max_length=255,blank=True) submitter_email = models.EmailField(blank=True) diff --git a/ietf/liaisons/models.py b/ietf/liaisons/models.py index 6302bea779..f357d6cf8c 100644 --- a/ietf/liaisons/models.py +++ b/ietf/liaisons/models.py @@ -44,7 +44,7 @@ class LiaisonStatement(models.Model): body = models.TextField(blank=True) tags = models.ManyToManyField(LiaisonStatementTagName, blank=True) - attachments = models.ManyToManyField(Document, through='LiaisonStatementAttachment', blank=True) + attachments = models.ManyToManyField(Document, through='liaisons.LiaisonStatementAttachment', blank=True) state = ForeignKey(LiaisonStatementState, default='pending') class Meta: 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/mailtrigger/models.py b/ietf/mailtrigger/models.py index 1cf84033b3..aea530083a 100644 --- a/ietf/mailtrigger/models.py +++ b/ietf/mailtrigger/models.py @@ -36,8 +36,8 @@ def clean_duplicates(addrlist): class MailTrigger(models.Model): slug = models.CharField(max_length=64, primary_key=True) desc = models.TextField(blank=True) - to = models.ManyToManyField('Recipient', blank=True, related_name='used_in_to') - cc = models.ManyToManyField('Recipient', blank=True, related_name='used_in_cc') + to = models.ManyToManyField('mailtrigger.Recipient', blank=True, related_name='used_in_to') + cc = models.ManyToManyField('mailtrigger.Recipient', blank=True, related_name='used_in_cc') class Meta: ordering = ["slug"] diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py index ef6a2721e9..2cec669db9 100644 --- a/ietf/meeting/forms.py +++ b/ietf/meeting/forms.py @@ -341,7 +341,7 @@ def save_agenda(self): # FIXME: What about agendas in html or markdown format? uploaded_filename='{}-00.txt'.format(filename)) doc.set_state(State.objects.get(type__slug=doc.type.slug, slug='active')) - self.instance.sessionpresentation_set.create(document=doc, rev=doc.rev) + self.instance.presentations.create(document=doc, rev=doc.rev) NewRevisionDocEvent.objects.create( type='new_revision', by=self.user.person, @@ -380,7 +380,8 @@ def save(self, *args, **kwargs): class InterimCancelForm(forms.Form): group = forms.CharField(max_length=255, required=False) date = forms.DateField(required=False) - comments = forms.CharField(required=False, widget=forms.Textarea(attrs={'placeholder': 'enter optional comments here'}), strip=False) + # max_length must match Session.agenda_note + comments = forms.CharField(max_length=512, required=False, widget=forms.Textarea(attrs={'placeholder': 'enter optional comments here'}), strip=False) def __init__(self, *args, **kwargs): super(InterimCancelForm, self).__init__(*args, **kwargs) @@ -471,6 +472,9 @@ def __init__(self, show_apply_to_all_checkbox, *args, **kwargs): class UploadMinutesForm(ApplyToAllFileUploadForm): doc_type = 'minutes' +class UploadNarrativeMinutesForm(ApplyToAllFileUploadForm): + doc_type = 'narrativeminutes' + class UploadAgendaForm(ApplyToAllFileUploadForm): doc_type = 'agenda' diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py index 14478787fd..c0e250cdc0 100644 --- a/ietf/meeting/helpers.py +++ b/ietf/meeting/helpers.py @@ -104,7 +104,7 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe queryset=add_event_info_to_session_qs(Session.objects.all().prefetch_related( 'group', 'group__charter', 'group__charter__group', Prefetch('materials', - queryset=Document.objects.exclude(states__type=F("type"), states__slug='deleted').order_by('sessionpresentation__order').prefetch_related('states'), + queryset=Document.objects.exclude(states__type=F("type"), states__slug='deleted').order_by('presentations__order').prefetch_related('states'), to_attr='prefetched_active_materials' ) )) @@ -890,7 +890,7 @@ def make_materials_directories(meeting): # was merged with the regular datatracker code; then in secr/proceedings/views.py # in make_directories()) saved_umask = os.umask(0) - for leaf in ('slides','agenda','minutes','id','rfc','bluesheets'): + for leaf in ('slides','agenda','minutes', 'narrativeminutes', 'id','rfc','bluesheets'): target = os.path.join(path,leaf) if not os.path.exists(target): os.makedirs(target) diff --git a/ietf/meeting/management/commands/import_iesg_minutes.py b/ietf/meeting/management/commands/import_iesg_minutes.py new file mode 100644 index 0000000000..d62e5058e4 --- /dev/null +++ b/ietf/meeting/management/commands/import_iesg_minutes.py @@ -0,0 +1,364 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from collections import namedtuple +import csv +import datetime +import os +import re +import shutil + +from django.conf import settings +from django.core.management import BaseCommand + +from pathlib import Path +from zoneinfo import ZoneInfo +from ietf.doc.models import DocEvent, Document + +from ietf.meeting.models import ( + Meeting, + SchedTimeSessAssignment, + Schedule, + SchedulingEvent, + Session, + TimeSlot, +) +from ietf.name.models import DocTypeName + + +def add_time_of_day(bare_datetime): + """Add a time for the iesg meeting based on a date and make it tzaware + + From the secretariat - the telechats happened at these times: + 2015-04-09 to present: 0700 PT America/Los Angeles + 1993-02-01 to 2015-03-12: 1130 ET America/New York + 1991-07-30 to 1993-01-25: 1200 ET America/New York + """ + dt = None + if bare_datetime.year > 2015: + dt = bare_datetime.replace(hour=7).replace( + tzinfo=ZoneInfo("America/Los_Angeles") + ) + elif bare_datetime.year == 2015: + if bare_datetime.month >= 4: + dt = bare_datetime.replace(hour=7).replace( + tzinfo=ZoneInfo("America/Los_Angeles") + ) + else: + dt = bare_datetime.replace(hour=11, minute=30).replace( + tzinfo=ZoneInfo("America/New_York") + ) + elif bare_datetime.year > 1993: + dt = bare_datetime.replace(hour=11, minute=30).replace( + tzinfo=ZoneInfo("America/New_York") + ) + elif bare_datetime.year == 1993: + if bare_datetime.month >= 2: + dt = bare_datetime.replace(hour=11, minute=30).replace( + tzinfo=ZoneInfo("America/New_York") + ) + else: + dt = bare_datetime.replace(hour=12).replace( + tzinfo=ZoneInfo("America/New_York") + ) + else: + dt = bare_datetime.replace(hour=12).replace(tzinfo=ZoneInfo("America/New_York")) + + return dt.astimezone(datetime.timezone.utc) + + +def build_bof_coord_data(): + CoordTuple = namedtuple("CoordTuple", "meeting_number source_name") + + def utc_from_la_time(time): + return time.replace(tzinfo=ZoneInfo("America/Los_Angeles")).astimezone( + datetime.timezone.utc + ) + + data = dict() + data[utc_from_la_time(datetime.datetime(2016, 6, 10, 7, 0))] = CoordTuple( + 96, "2015/bof-minutes-ietf-96.txt" + ) + data[utc_from_la_time(datetime.datetime(2016, 10, 6, 7, 0))] = CoordTuple( + 97, "2016/BoF-Minutes-2016-10-06.txt" + ) + data[utc_from_la_time(datetime.datetime(2017, 2, 15, 8, 0))] = CoordTuple( + 98, "2017/bof-minutes-ietf-98.txt" + ) + data[utc_from_la_time(datetime.datetime(2017, 6, 7, 8, 0))] = CoordTuple( + 99, "2017/bof-minutes-ietf-99.txt" + ) + data[utc_from_la_time(datetime.datetime(2017, 10, 5, 7, 0))] = CoordTuple( + 100, "2017/bof-minutes-ietf-100.txt" + ) + data[utc_from_la_time(datetime.datetime(2018, 2, 5, 11, 0))] = CoordTuple( + 101, "2018/bof-minutes-ietf-101.txt" + ) + data[utc_from_la_time(datetime.datetime(2018, 6, 5, 8, 0))] = CoordTuple( + 102, "2018/bof-minutes-ietf-102.txt" + ) + data[utc_from_la_time(datetime.datetime(2018, 9, 26, 7, 0))] = CoordTuple( + 103, "2018/bof-minutes-ietf-103.txt" + ) + data[utc_from_la_time(datetime.datetime(2019, 2, 15, 9, 0))] = CoordTuple( + 104, "2019/bof-minutes-ietf-104.txt" + ) + data[utc_from_la_time(datetime.datetime(2019, 6, 11, 7, 30))] = CoordTuple( + 105, "2019/bof-minutes-ietf-105.txt" + ) + data[utc_from_la_time(datetime.datetime(2019, 10, 9, 6, 30))] = CoordTuple( + 106, "2019/bof-minutes-ietf-106.txt" + ) + data[utc_from_la_time(datetime.datetime(2020, 2, 13, 8, 0))] = CoordTuple( + 107, "2020/bof-minutes-ietf-107.txt" + ) + data[utc_from_la_time(datetime.datetime(2020, 6, 15, 8, 0))] = CoordTuple( + 108, "2020/bof-minutes-ietf-108.txt" + ) + data[utc_from_la_time(datetime.datetime(2020, 10, 9, 7, 0))] = CoordTuple( + 109, "2020/bof-minutes-ietf-109.txt" + ) + data[utc_from_la_time(datetime.datetime(2021, 1, 14, 13, 30))] = CoordTuple( + 110, "2021/bof-minutes-ietf-110.txt" + ) + data[utc_from_la_time(datetime.datetime(2021, 6, 1, 8, 0))] = CoordTuple( + 111, "2021/bof-minutes-ietf-111.txt" + ) + data[utc_from_la_time(datetime.datetime(2021, 9, 15, 9, 0))] = CoordTuple( + 112, "2021/bof-minutes-ietf-112.txt" + ) + data[utc_from_la_time(datetime.datetime(2022, 1, 28, 7, 0))] = CoordTuple( + 113, "2022/bof-minutes-ietf-113.txt" + ) + data[utc_from_la_time(datetime.datetime(2022, 6, 2, 10, 0))] = CoordTuple( + 114, "2022/bof-minutes-ietf-114.txt" + ) + data[utc_from_la_time(datetime.datetime(2022, 9, 13, 9, 0))] = CoordTuple( + 115, "2022/bof-minutes-ietf-115.txt" + ) + data[utc_from_la_time(datetime.datetime(2023, 2, 1, 9, 0))] = CoordTuple( + 116, "2023/bof-minutes-ietf-116.txt" + ) + data[utc_from_la_time(datetime.datetime(2023, 6, 1, 7, 0))] = CoordTuple( + 117, "2023/bof-minutes-ietf-117.txt" + ) + data[utc_from_la_time(datetime.datetime(2023, 9, 15, 8, 0))] = CoordTuple( + 118, "2023/bof-minutes-ietf-118.txt" + ) + return data + + +class Command(BaseCommand): + help = "Performs a one-time import of IESG minutes, creating Meetings to attach them to" + + def handle(self, *args, **options): + old_minutes_root = ( + "/a/www/www6/iesg/minutes" + if settings.SERVER_MODE == "production" + else "/assets/www6/iesg/minutes" + ) + minutes_dir = Path(old_minutes_root) + date_re = re.compile(r"\d{4}-\d{2}-\d{2}") + meeting_times = set() + redirects = [] + for file_prefix in ["minutes", "narrative"]: + paths = list(minutes_dir.glob(f"[12][09][0129][0-9]/{file_prefix}*.txt")) + paths.extend( + list(minutes_dir.glob(f"[12][09][0129][0-9]/{file_prefix}*.html")) + ) + for path in paths: + s = date_re.search(path.name) + if s: + meeting_times.add( + add_time_of_day( + datetime.datetime.strptime(s.group(), "%Y-%m-%d") + ) + ) + bof_coord_data = build_bof_coord_data() + bof_times = set(bof_coord_data.keys()) + assert len(bof_times.intersection(meeting_times)) == 0 + meeting_times.update(bof_times) + year_seen = None + for dt in sorted(meeting_times): + if dt.year != year_seen: + counter = 1 + year_seen = dt.year + meeting_name = f"interim-{dt.year}-iesg-{counter:02d}" + meeting = Meeting.objects.create( + number=meeting_name, + type_id="interim", + date=dt.date(), + days=1, + time_zone=dt.tzname(), + ) + schedule = Schedule.objects.create( + meeting=meeting, + owner_id=1, # the "(System)" person + visible=True, + public=True, + ) + meeting.schedule = schedule + meeting.save() + session = Session.objects.create( + meeting=meeting, + group_id=2, # The IESG group + type_id="regular", + purpose_id="regular", + name=( + f"IETF {bof_coord_data[dt].meeting_number} BOF Coordination Call" + if dt in bof_times + else "Formal Telechat" + ), + ) + SchedulingEvent.objects.create( + session=session, + status_id="sched", + by_id=1, # (System) + ) + timeslot = TimeSlot.objects.create( + meeting=meeting, + type_id="regular", + time=dt, + duration=datetime.timedelta(seconds=2 * 60 * 60), + ) + SchedTimeSessAssignment.objects.create( + timeslot=timeslot, session=session, schedule=schedule + ) + + if dt in bof_times: + source = minutes_dir / bof_coord_data[dt].source_name + if source.exists(): + doc_name = ( + f"minutes-interim-{dt.year}-iesg-{counter:02d}-{dt:%Y%m%d%H%M}" + ) + doc_filename = f"{doc_name}-00.txt" + doc = Document.objects.create( + name=doc_name, + type_id="minutes", + title=f"Minutes IETF {bof_coord_data[dt].meeting_number} BOF coordination {meeting_name} {dt:%Y-%m-%d %H:%M}", + group_id=2, # the IESG group + rev="00", + uploaded_filename=doc_filename, + ) + e = DocEvent.objects.create( + type="comment", + doc=doc, + rev="00", + by_id=1, # "(System)" + desc="Minutes moved into datatracker", + ) + doc.save_with_history([e]) + session.presentations.create(document=doc, rev=doc.rev) + dest = ( + Path(settings.AGENDA_PATH) + / meeting_name + / "minutes" + / doc_filename + ) + if dest.exists(): + self.stdout.write( + f"WARNING: {dest} already exists - not overwriting it." + ) + else: + os.makedirs(dest.parent, exist_ok=True) + shutil.copy(source, dest) + redirects.append( + [ + f"www6.ietf.org/iesg/minutes/{dt.year}/{bof_coord_data[dt].source_name}", + f"https://datatracker.ietf.org/doc/{doc_name}", + 302, + ] + ) + else: + for type_id in ["minutes", "narrativeminutes"]: + source_file_prefix = ( + "minutes" if type_id == "minutes" else "narrative-minutes" + ) + txt_source = ( + minutes_dir + / f"{dt.year}" + / f"{source_file_prefix}-{dt:%Y-%m-%d}.txt" + ) + html_source = ( + minutes_dir + / f"{dt.year}" + / f"{source_file_prefix}-{dt:%Y-%m-%d}.html" + ) + if txt_source.exists() and html_source.exists(): + self.stdout.write( + f"WARNING: Both {txt_source} and {html_source} exist." + ) + if txt_source.exists() or html_source.exists(): + prefix = DocTypeName.objects.get(slug=type_id).prefix + doc_name = f"{prefix}-interim-{dt.year}-iesg-{counter:02d}-{dt:%Y%m%d%H%M}" + suffix = "html" if html_source.exists() else "txt" + doc_filename = f"{doc_name}-00.{suffix}" + verbose_type = ( + "Minutes" if type_id == "minutes" else "Narrative Minutes" + ) + doc = Document.objects.create( + name=doc_name, + type_id=type_id, + title=f"{verbose_type} {meeting_name} {dt:%Y-%m-%d %H:%M}", + group_id=2, # the IESG group + rev="00", + uploaded_filename=doc_filename, + ) + e = DocEvent.objects.create( + type="comment", + doc=doc, + rev="00", + by_id=1, # "(System)" + desc=f"{verbose_type} moved into datatracker", + ) + doc.save_with_history([e]) + session.presentations.create(document=doc, rev=doc.rev) + dest = ( + Path(settings.AGENDA_PATH) + / meeting_name + / type_id + / doc_filename + ) + if dest.exists(): + self.stdout.write( + f"WARNING: {dest} already exists - not overwriting it." + ) + else: + os.makedirs(dest.parent, exist_ok=True) + if html_source.exists(): + html_content = html_source.read_text(encoding="utf-8") + html_content = html_content.replace( + f'href="IESGnarrative-{dt:%Y-%m-%d}.html#', + 'href="#', + ) + html_content = re.sub( + r']*>([^<]*)', + r"\1", + html_content, + ) + html_content = re.sub( + r'([^<]*)', + r"\1", + html_content, + ) + html_content = re.sub( + ' 0 and self.functional_name != self.name: + return f"{self.name} [{self.functional_name}] (size: {self.capacity})" + return f"{self.name} (size: {self.capacity})" def dom_id(self): return "room%u" % (self.pk) @@ -496,14 +477,6 @@ def right(self): return max(self.x1, self.x2) if (self.x1 and self.x2) else 0 def bottom(self): return max(self.y1, self.y2) if (self.y1 and self.y2) else 0 - def functional_display_name(self): - if not self.functional_name: - return "" - if 'breakout' in self.functional_name.lower(): - return "" - if self.functional_name[0].isdigit(): - return "" - return self.functional_name # audio stream support def audio_stream_url(self): urlresources = [ur for ur in self.urlresource_set.all() if ur.name_id == 'audiostream'] @@ -569,7 +542,7 @@ class TimeSlot(models.Model): duration = models.DurationField(default=datetime.timedelta(0)) location = ForeignKey(Room, blank=True, null=True) show_location = models.BooleanField(default=True, help_text="Show location in agenda.") - sessions = models.ManyToManyField('Session', related_name='slots', through='SchedTimeSessAssignment', blank=True, help_text="Scheduled session, if any.") + sessions = models.ManyToManyField('meeting.Session', related_name='slots', through='meeting.SchedTimeSessAssignment', blank=True, help_text="Scheduled session, if any.") modified = models.DateTimeField(auto_now=True) # @@ -775,9 +748,6 @@ def official_token(self): else: return "unofficial" - def delete_assignments(self): - self.assignments.all().delete() - @property def qs_assignments_with_sessions(self): return self.assignments.filter(session__isnull=False) @@ -790,10 +760,6 @@ def qs_sessions_scheduled(self): """Get QuerySet containing sessions assigned to timeslots by this schedule""" return Session.objects.filter(timeslotassignments__schedule=self) - def delete_schedule(self): - self.assignments.all().delete() - self.delete() - # to be renamed SchedTimeSessAssignments (stsa) class SchedTimeSessAssignment(models.Model): """ @@ -939,8 +905,8 @@ def brief_display(self): class SessionPresentation(models.Model): - session = ForeignKey('Session') - document = ForeignKey(Document) + session = ForeignKey('Session', related_name="presentations") + document = ForeignKey(Document, related_name="presentations") rev = models.CharField(verbose_name="revision", max_length=16, null=True, blank=True) order = models.PositiveSmallIntegerField(default=0) @@ -1052,7 +1018,7 @@ class Session(models.Model): group = ForeignKey(Group) # The group type historically determined the session type. BOFs also need to be added as a group. Note that not all meeting requests have a natural group to associate with. joint_with_groups = models.ManyToManyField(Group, related_name='sessions_joint_in',blank=True) attendees = models.IntegerField(null=True, blank=True) - agenda_note = models.CharField(blank=True, max_length=255) + agenda_note = models.CharField(blank=True, max_length=512) requested_duration = models.DurationField(default=datetime.timedelta(0)) comments = models.TextField(blank=True) scheduled = models.DateTimeField(null=True, blank=True) @@ -1079,7 +1045,7 @@ def get_material(self, material_type, only_one): for d in l: d.meeting_related = lambda: True else: - l = self.materials.filter(type=material_type).exclude(states__type=material_type, states__slug='deleted').order_by('sessionpresentation__order') + l = self.materials.filter(type=material_type).exclude(states__type=material_type, states__slug='deleted').order_by('presentations__order') if only_one: if l: @@ -1099,16 +1065,25 @@ def minutes(self): self._cached_minutes = self.get_material("minutes", only_one=True) return self._cached_minutes + def narrative_minutes(self): + if not hasattr(self, '_cached_narrative_minutes'): + self._cached_minutes = self.get_material("narrativeminutes", only_one=True) + return self._cached_minutes + def recordings(self): return list(self.get_material("recording", only_one=False)) def bluesheets(self): return list(self.get_material("bluesheets", only_one=False)) + def chatlogs(self): + return list(self.get_material("chatlog", only_one=False)) + def slides(self): if not hasattr(self, "_slides_cache"): self._slides_cache = list(self.get_material("slides", only_one=False)) return self._slides_cache + def drafts(self): return list(self.materials.filter(type='draft')) @@ -1143,30 +1118,6 @@ def order_in_meeting(self): self._order_in_meeting = session_list.index(self) + 1 if self in session_list else 0 return self._order_in_meeting - def all_meeting_sessions_cancelled(self): - return set(s.current_status for s in self.all_meeting_sessions_for_group()) == {'canceled'} - - def all_meeting_recordings(self): - recordings = [] # These are not sets because we need to preserve relative ordering or redo the ordering work later - sessions = self.all_meeting_sessions_for_group() - for session in sessions: - recordings.extend([r for r in session.recordings() if r not in recordings]) - return recordings - - def all_meeting_bluesheets(self): - bluesheets = [] - sessions = self.all_meeting_sessions_for_group() - for session in sessions: - bluesheets.extend([b for b in session.bluesheets() if b not in bluesheets]) - return bluesheets - - def all_meeting_drafts(self): - drafts = [] - sessions = self.all_meeting_sessions_for_group() - for session in sessions: - drafts.extend([d for d in session.drafts() if d not in drafts]) - return drafts - def all_meeting_agendas(self): agendas = [] sessions = self.all_meeting_sessions_for_group() @@ -1283,19 +1234,6 @@ def agenda_text(self): else: return "The agenda has not been uploaded yet." - def agenda_file(self): - if not hasattr(self, '_agenda_file'): - self._agenda_file = "" - - agenda = self.agenda() - if not agenda: - return "" - - # FIXME: uploaded_filename should be replaced with a function that computes filenames when they are of a fixed schema and not uploaded names - self._agenda_file = "%s/agenda/%s" % (self.meeting.number, agenda.uploaded_filename) - - return self._agenda_file - def chat_room_name(self): if self.chat_room: return self.chat_room @@ -1309,10 +1247,21 @@ def chat_room_url(self): return settings.CHAT_URL_PATTERN.format(chat_room_name=self.chat_room_name()) def chat_archive_url(self): - chatlog = self.sessionpresentation_set.filter(document__type__slug='chatlog').first() - if chatlog is not None: - return chatlog.document.get_href() - elif self.meeting.date <= datetime.date(2022, 7, 15): + + if hasattr(self,"prefetched_active_materials"): + chatlog_doc = None + for doc in self.prefetched_active_materials: + if doc.type_id=="chatlog": + chatlog_doc = doc + break + if chatlog_doc is not None: + return chatlog_doc.get_href() + else: + chatlog = self.presentations.filter(document__type__slug='chatlog').first() + if chatlog is not None: + return chatlog.document.get_href() + + if self.meeting.date <= datetime.date(2022, 7, 15): # datatracker 8.8.0 released on 2022 July 15; before that, fall back to old log URL return f'https://www.ietf.org/jabber/logs/{ self.chat_room_name() }?C=M;O=D' elif hasattr(settings,'CHAT_ARCHIVE_URL_PATTERN'): @@ -1339,29 +1288,27 @@ def group_parent_at_the_time(self): return self.meeting.group_at_the_time(self.group_at_the_time().parent) def audio_stream_url(self): - if ( - self.meeting.type.slug == "ietf" - and self.has_onsite_tool - and (url := getattr(settings, "MEETECHO_AUDIO_STREAM_URL", "")) - ): + url = getattr(settings, "MEETECHO_AUDIO_STREAM_URL", "") + if self.meeting.type.slug == "ietf" and self.has_onsite_tool and url: return url.format(session=self) return None def video_stream_url(self): - if ( - self.meeting.type.slug == "ietf" - and self.has_onsite_tool - and (url := getattr(settings, "MEETECHO_VIDEO_STREAM_URL", "")) - ): + url = getattr(settings, "MEETECHO_VIDEO_STREAM_URL", "") + if self.meeting.type.slug == "ietf" and self.has_onsite_tool and url: return url.format(session=self) return None def onsite_tool_url(self): - if ( - self.meeting.type.slug == "ietf" - and self.has_onsite_tool - and (url := getattr(settings, "MEETECHO_ONSITE_TOOL_URL", "")) - ): + url = getattr(settings, "MEETECHO_ONSITE_TOOL_URL", "") + if self.meeting.type.slug == "ietf" and self.has_onsite_tool and url: + return url.format(session=self) + return None + + def session_recording_url(self): + url = getattr(settings, "MEETECHO_SESSION_RECORDING_URL", "") + if self.meeting.type.slug == "ietf" and self.has_onsite_tool and url: + self.group.acronym_upper = self.group.acronym.upper() return url.format(session=self) return None @@ -1479,6 +1426,8 @@ class Meta: class Attended(models.Model): person = ForeignKey(Person) session = ForeignKey(Session) + time = models.DateTimeField(default=timezone.now, null=True, blank=True) + origin = models.CharField(max_length=32, default='datatracker') class Meta: unique_together = (('person', 'session'),) diff --git a/ietf/meeting/templatetags/proceedings_filters.py b/ietf/meeting/templatetags/proceedings_filters.py index f5fe0e1f14..a2a4932e7c 100644 --- a/ietf/meeting/templatetags/proceedings_filters.py +++ b/ietf/meeting/templatetags/proceedings_filters.py @@ -11,7 +11,7 @@ def hack_recording_title(recording,add_timestamp=False): if recording.title.startswith('Audio recording for') or recording.title.startswith('Video recording for'): hacked_title = recording.title[:15] if add_timestamp: - hacked_title += ' '+recording.sessionpresentation_set.first().session.official_timeslotassignment().timeslot.time.strftime("%a %H:%M") + hacked_title += ' '+recording.presentations.first().session.official_timeslotassignment().timeslot.time.strftime("%a %H:%M") return hacked_title else: return recording.title diff --git a/ietf/meeting/templatetags/session_filters.py b/ietf/meeting/templatetags/session_filters.py index 4fe377a813..3846dab49e 100644 --- a/ietf/meeting/templatetags/session_filters.py +++ b/ietf/meeting/templatetags/session_filters.py @@ -8,7 +8,7 @@ @register.filter def presented_versions(session, doc): - sp = session.sessionpresentation_set.filter(document=doc) + sp = session.presentations.filter(document=doc) if not sp: return "Document not in session" else: diff --git a/ietf/meeting/test_data.py b/ietf/meeting/test_data.py index 5ecb494df2..8be55b47a2 100644 --- a/ietf/meeting/test_data.py +++ b/ietf/meeting/test_data.py @@ -51,7 +51,7 @@ def make_interim_meeting(group,date,status='sched',tz='UTC'): doc = DocumentFactory.create(name=name, type_id='agenda', title="Agenda", uploaded_filename=file, group=group, rev=rev, states=[('draft','active')]) pres = SessionPresentation.objects.create(session=session, document=doc, rev=doc.rev) - session.sessionpresentation_set.add(pres) + session.presentations.add(pres) # minutes name = "minutes-%s-%s" % (meeting.number, time.strftime("%Y%m%d%H%M")) rev = '00' @@ -59,7 +59,7 @@ def make_interim_meeting(group,date,status='sched',tz='UTC'): doc = DocumentFactory.create(name=name, type_id='minutes', title="Minutes", uploaded_filename=file, group=group, rev=rev, states=[('draft','active')]) pres = SessionPresentation.objects.create(session=session, document=doc, rev=doc.rev) - session.sessionpresentation_set.add(pres) + session.presentations.add(pres) # slides title = "Slideshow" @@ -70,7 +70,7 @@ def make_interim_meeting(group,date,status='sched',tz='UTC'): uploaded_filename=file, group=group, rev=rev, states=[('slides','active'), ('reuse_policy', 'single')]) pres = SessionPresentation.objects.create(session=session, document=doc, rev=doc.rev) - session.sessionpresentation_set.add(pres) + session.presentations.add(pres) # return meeting @@ -198,24 +198,24 @@ def make_meeting_test_data(meeting=None, create_interims=False): doc = DocumentFactory.create(name='agenda-72-mars', type_id='agenda', title="Agenda", uploaded_filename="agenda-72-mars.txt", group=mars, rev='00', states=[('agenda','active')]) pres = SessionPresentation.objects.create(session=mars_session,document=doc,rev=doc.rev) - mars_session.sessionpresentation_set.add(pres) # + mars_session.presentations.add(pres) # doc = DocumentFactory.create(name='minutes-72-mars', type_id='minutes', title="Minutes", uploaded_filename="minutes-72-mars.md", group=mars, rev='00', states=[('minutes','active')]) pres = SessionPresentation.objects.create(session=mars_session,document=doc,rev=doc.rev) - mars_session.sessionpresentation_set.add(pres) + mars_session.presentations.add(pres) doc = DocumentFactory.create(name='slides-72-mars-1-active', type_id='slides', title="Slideshow", uploaded_filename="slides-72-mars.txt", group=mars, rev='00', states=[('slides','active'), ('reuse_policy', 'single')]) pres = SessionPresentation.objects.create(session=mars_session,document=doc,rev=doc.rev) - mars_session.sessionpresentation_set.add(pres) + mars_session.presentations.add(pres) doc = DocumentFactory.create(name='slides-72-mars-2-deleted', type_id='slides', title="Bad Slideshow", uploaded_filename="slides-72-mars-2-deleted.txt", group=mars, rev='00', states=[('slides','deleted'), ('reuse_policy', 'single')]) pres = SessionPresentation.objects.create(session=mars_session,document=doc,rev=doc.rev) - mars_session.sessionpresentation_set.add(pres) + mars_session.presentations.add(pres) # Future Interim Meetings date = date_today() + datetime.timedelta(days=365) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 517836f876..6199ed7eb5 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -884,9 +884,9 @@ class SlideReorderTests(IetfSeleniumTestCase): def setUp(self): super(SlideReorderTests, self).setUp() self.session = SessionFactory(meeting__type_id='ietf', status_id='sched') - self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='one'),order=1) - self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='two'),order=2) - self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='three'),order=3) + self.session.presentations.create(document=DocumentFactory(type_id='slides',name='one'),order=1) + self.session.presentations.create(document=DocumentFactory(type_id='slides',name='two'),order=2) + self.session.presentations.create(document=DocumentFactory(type_id='slides',name='three'),order=3) def secr_login(self): self.login('secretary') @@ -906,7 +906,7 @@ def testReorderSlides(self): ActionChains(self.driver).drag_and_drop(second,third).perform() time.sleep(0.1) # The API that modifies the database runs async - names=self.session.sessionpresentation_set.values_list('document__name',flat=True) + names=self.session.presentations.values_list('document__name',flat=True) self.assertEqual(list(names),['one','three','two']) @ifSeleniumEnabled diff --git a/ietf/meeting/tests_schedule_forms.py b/ietf/meeting/tests_schedule_forms.py index 58c1332bd5..426d26dc2d 100644 --- a/ietf/meeting/tests_schedule_forms.py +++ b/ietf/meeting/tests_schedule_forms.py @@ -140,13 +140,13 @@ def test_location_options(self): rendered = str(TimeSlotEditForm(instance=ts)['location']) # noinspection PyTypeChecker self.assertInHTML( - f'', + f'', rendered, ) for room in rooms: # noinspection PyTypeChecker self.assertInHTML( - f'', + f'', rendered, ) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 47e2334f47..75babd86c2 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2020, All Rights Reserved +# Copyright The IETF Trust 2009-2024, All Rights Reserved # -*- coding: utf-8 -*- import datetime import io @@ -12,7 +12,7 @@ import requests_mock from unittest import skipIf -from mock import patch, PropertyMock +from mock import call, patch, PropertyMock from pyquery import PyQuery from lxml.etree import tostring from io import StringIO, BytesIO @@ -38,16 +38,16 @@ from ietf.doc.models import Document, NewRevisionDocEvent from ietf.group.models import Group, Role, GroupFeatures from ietf.group.utils import can_manage_group -from ietf.person.models import Person +from ietf.person.models import Person, PersonalApiKey from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_request, preprocess_assignments_for_agenda from ietf.meeting.helpers import send_interim_approval_request, AgendaKeywordTagger from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room, Constraint, ConstraintName from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data -from ietf.meeting.utils import finalize, condition_slide_order +from ietf.meeting.utils import condition_slide_order from ietf.meeting.utils import add_event_info_to_session_qs, participants_for_meeting -from ietf.meeting.utils import create_recording, get_next_sequence +from ietf.meeting.utils import create_recording, get_next_sequence, bluesheet_data from ietf.meeting.views import session_draft_list, parse_agenda_filter_params, sessions_post_save, agenda_extract_schedule from ietf.meeting.views import get_summary_by_area, get_summary_by_type, get_summary_by_purpose from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, ProceedingsMaterialTypeName @@ -468,16 +468,16 @@ def test_materials_through_cdn(self): doc = DocumentFactory.create(name='agenda-172-mars', type_id='agenda', title="Agenda", uploaded_filename="agenda-172-mars.txt", group=session107.group, rev='00', states=[('agenda','active')]) pres = SessionPresentation.objects.create(session=session107,document=doc,rev=doc.rev) - session107.sessionpresentation_set.add(pres) # + session107.presentations.add(pres) # doc = DocumentFactory.create(name='minutes-172-mars', type_id='minutes', title="Minutes", uploaded_filename="minutes-172-mars.md", group=session107.group, rev='00', states=[('minutes','active')]) pres = SessionPresentation.objects.create(session=session107,document=doc,rev=doc.rev) - session107.sessionpresentation_set.add(pres) + session107.presentations.add(pres) doc = DocumentFactory.create(name='slides-172-mars-1-active', type_id='slides', title="Slideshow", uploaded_filename="slides-172-mars.txt", group=session107.group, rev='00', states=[('slides','active'), ('reuse_policy', 'single')]) pres = SessionPresentation.objects.create(session=session107,document=doc,rev=doc.rev) - session107.sessionpresentation_set.add(pres) + session107.presentations.add(pres) for session in ( Session.objects.filter(meeting=meeting, group__acronym="mars").first(), @@ -517,7 +517,7 @@ def test_named_session(self): group = GroupFactory() plain_session = SessionFactory(meeting=meeting, group=group) named_session = SessionFactory(meeting=meeting, group=group, name='I Got a Name') - for doc_type_id in ('agenda', 'minutes', 'bluesheets', 'slides', 'draft'): + for doc_type_id in ('agenda', 'minutes', 'slides', 'draft'): # Set up sessions materials that will have distinct URLs for each session. # This depends on settings.MEETING_DOC_HREFS and may need updating if that changes. SessionPresentationFactory( @@ -548,7 +548,7 @@ def test_named_session(self): named_row = named_label.closest('tr') self.assertTrue(named_row) - for material in (sp.document for sp in plain_session.sessionpresentation_set.all()): + for material in (sp.document for sp in plain_session.presentations.all()): if material.type_id == 'draft': expected_url = urlreverse( 'ietf.doc.views_doc.document_main', @@ -559,7 +559,7 @@ def test_named_session(self): self.assertTrue(plain_row.find(f'a[href="{expected_url}"]')) self.assertFalse(named_row.find(f'a[href="{expected_url}"]')) - for material in (sp.document for sp in named_session.sessionpresentation_set.all()): + for material in (sp.document for sp in named_session.presentations.all()): if material.type_id == 'draft': expected_url = urlreverse( 'ietf.doc.views_doc.document_main', @@ -955,10 +955,10 @@ def build_session_setup(self): # but lists a different on in its agenda. The expectation is that the pdf and tgz views will return both. session = SessionFactory(group__type_id='wg',meeting__type_id='ietf') draft1 = WgDraftFactory(group=session.group) - session.sessionpresentation_set.create(document=draft1) + session.presentations.create(document=draft1) draft2 = WgDraftFactory(group=session.group) agenda = DocumentFactory(type_id='agenda',group=session.group, uploaded_filename='agenda-%s-%s' % (session.meeting.number,session.group.acronym), states=[('agenda','active')]) - session.sessionpresentation_set.create(document=agenda) + session.presentations.create(document=agenda) self.write_materials_file(session.meeting, session.materials.get(type="agenda"), "1. WG status (15 minutes)\n\n2. Status of %s\n\n" % draft2.name) filenames = [] @@ -3020,7 +3020,9 @@ def test_ajax_delete_timeslots_invalid(self): class ReorderSlidesTests(TestCase): - def test_add_slides_to_session(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_add_slides_to_session(self, mock_slides_manager_cls): for type_id in ('ietf','interim'): chair_role = RoleFactory(name_id='chair') session = SessionFactory(group=chair_role.group, meeting__date=date_today() - datetime.timedelta(days=90), meeting__type_id=type_id) @@ -3031,6 +3033,7 @@ def test_add_slides_to_session(self): r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3038,6 +3041,7 @@ def test_add_slides_to_session(self): r = self.client.post(url, {'order':0, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3047,54 +3051,67 @@ def test_add_slides_to_session(self): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('No data',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'garbage':'garbage'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':0, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':2, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':'garbage', 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('order is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Invalid name r = self.client.post(url, {'order':1 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'order':1, 'name':'garbage' }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Valid post r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(session.sessionpresentation_set.count(),1) + self.assertEqual(session.presentations.count(),1) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session, slides=slides, order=1)) + mock_slides_manager_cls.reset_mock() # Ignore a request to add slides that are already in a session r = self.client.post(url, {'order':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(session.sessionpresentation_set.count(),1) + self.assertEqual(session.presentations.count(),1) + self.assertFalse(mock_slides_manager_cls.called) session2 = SessionFactory(group=session.group, meeting=session.meeting) SessionPresentationFactory.create_batch(3, document__type_id='slides', session=session2) - for num, sp in enumerate(session2.sessionpresentation_set.filter(document__type_id='slides'),start=1): + for num, sp in enumerate(session2.presentations.filter(document__type_id='slides'),start=1): sp.order = num sp.save() @@ -3106,24 +3123,41 @@ def test_add_slides_to_session(self): r = self.client.post(url, {'order':1, 'name':more_slides[0].name}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(session2.sessionpresentation_set.get(document=more_slides[0]).order,1) - self.assertEqual(list(session2.sessionpresentation_set.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertEqual(session2.presentations.get(document=more_slides[0]).order,1) + self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[0], order=1)) + mock_slides_manager_cls.reset_mock() # Insert at end r = self.client.post(url, {'order':5, 'name':more_slides[1].name}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(session2.sessionpresentation_set.get(document=more_slides[1]).order,5) - self.assertEqual(list(session2.sessionpresentation_set.order_by('order').values_list('order',flat=True)), list(range(1,6))) + self.assertEqual(session2.presentations.get(document=more_slides[1]).order,5) + self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,6))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[1], order=5)) + mock_slides_manager_cls.reset_mock() # Insert in middle r = self.client.post(url, {'order':3, 'name':more_slides[2].name}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(session2.sessionpresentation_set.get(document=more_slides[2]).order,3) - self.assertEqual(list(session2.sessionpresentation_set.order_by('order').values_list('order',flat=True)), list(range(1,7))) - - def test_remove_slides_from_session(self): + self.assertEqual(session2.presentations.get(document=more_slides[2]).order,3) + self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,7))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.add.called) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_args, call(session=session2, slides=more_slides[2], order=3)) + mock_slides_manager_cls.reset_mock() + + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_remove_slides_from_session(self, mock_slides_manager_cls): for type_id in ['ietf','interim']: chair_role = RoleFactory(name_id='chair') session = SessionFactory(group=chair_role.group, meeting__date=date_today()-datetime.timedelta(days=90), meeting__type_id=type_id) @@ -3134,6 +3168,7 @@ def test_remove_slides_from_session(self): r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3141,6 +3176,7 @@ def test_remove_slides_from_session(self): r = self.client.post(url, {'oldIndex':0, 'name':slides.name }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3150,40 +3186,47 @@ def test_remove_slides_from_session(self): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('No data',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'garbage':'garbage'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':0, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':'garbage', 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) - + self.assertFalse(mock_slides_manager_cls.called) + # No matching thing to delete r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) - session.sessionpresentation_set.create(document=slides, rev=slides.rev, order=1) + session.presentations.create(document=slides, rev=slides.rev, order=1) # Bad names r = self.client.post(url, {'oldIndex':1}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':1, 'name':'garbage' }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('name is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) slides2 = DocumentFactory(type_id='slides') @@ -3192,22 +3235,29 @@ def test_remove_slides_from_session(self): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('SessionPresentation not found',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) - session.sessionpresentation_set.create(document=slides2, rev=slides2.rev, order=2) + session.presentations.create(document=slides2, rev=slides2.rev, order=2) r = self.client.post(url, {'oldIndex':1, 'name':slides2.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('Name does not match index',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # valid removal r = self.client.post(url, {'oldIndex':1, 'name':slides.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(session.sessionpresentation_set.count(),1) + self.assertEqual(session.presentations.count(),1) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session, slides=slides)) + mock_slides_manager_cls.reset_mock() session2 = SessionFactory(group=session.group, meeting=session.meeting) sp_list = SessionPresentationFactory.create_batch(5, document__type_id='slides', session=session2) - for num, sp in enumerate(session2.sessionpresentation_set.filter(document__type_id='slides'),start=1): + for num, sp in enumerate(session2.presentations.filter(document__type_id='slides'),start=1): sp.order = num sp.save() @@ -3217,27 +3267,41 @@ def test_remove_slides_from_session(self): r = self.client.post(url, {'oldIndex':1, 'name':sp_list[0].document.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertFalse(session2.sessionpresentation_set.filter(pk=sp_list[0].pk).exists()) - self.assertEqual(list(session2.sessionpresentation_set.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertFalse(session2.presentations.filter(pk=sp_list[0].pk).exists()) + self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,5))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[0].document)) + mock_slides_manager_cls.reset_mock() # delete in middle of list r = self.client.post(url, {'oldIndex':4, 'name':sp_list[4].document.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertFalse(session2.sessionpresentation_set.filter(pk=sp_list[4].pk).exists()) - self.assertEqual(list(session2.sessionpresentation_set.order_by('order').values_list('order',flat=True)), list(range(1,4))) + self.assertFalse(session2.presentations.filter(pk=sp_list[4].pk).exists()) + self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,4))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[4].document)) + mock_slides_manager_cls.reset_mock() # delete at end of list r = self.client.post(url, {'oldIndex':2, 'name':sp_list[2].document.name }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertFalse(session2.sessionpresentation_set.filter(pk=sp_list[2].pk).exists()) - self.assertEqual(list(session2.sessionpresentation_set.order_by('order').values_list('order',flat=True)), list(range(1,3))) - - - - - def test_reorder_slides_in_session(self): + self.assertFalse(session2.presentations.filter(pk=sp_list[2].pk).exists()) + self.assertEqual(list(session2.presentations.order_by('order').values_list('order',flat=True)), list(range(1,3))) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.delete.called) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_args, call(session=session2, slides=sp_list[2].document)) + mock_slides_manager_cls.reset_mock() + + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_reorder_slides_in_session(self, mock_slides_manager_cls): def _sppk_at(sppk, positions): return [sppk[p-1] for p in positions] chair_role = RoleFactory(name_id='chair') @@ -3259,6 +3323,7 @@ def _sppk_at(sppk, positions): r = self.client.post(url, {'oldIndex':1, 'newIndex':2 }) self.assertEqual(r.status_code, 403) self.assertIn('have permission', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) self.client.login(username=chair_role.person.user.username, password=chair_role.person.user.username+"+password") @@ -3266,6 +3331,7 @@ def _sppk_at(sppk, positions): r = self.client.post(url, {'oldIndex':1, 'newIndex':2 }) self.assertEqual(r.status_code, 403) self.assertIn('materials cutoff', unicontent(r)) + self.assertFalse(mock_slides_manager_cls.called) session.meeting.date = date_today() session.meeting.save() @@ -3275,60 +3341,98 @@ def _sppk_at(sppk, positions): self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':2, 'newIndex':6 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) r = self.client.post(url, {'oldIndex':2, 'newIndex':2 }) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],False) self.assertIn('index is not valid',r.json()['error']) + self.assertFalse(mock_slides_manager_cls.called) # Move from beginning r = self.client.post(url, {'oldIndex':1, 'newIndex':3}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,1,4,5])) + self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,1,4,5])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move to beginning r = self.client.post(url, {'oldIndex':3, 'newIndex':1}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) - + self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() + # Move from end r = self.client.post(url, {'oldIndex':5, 'newIndex':3}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,5,3,4])) + self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,5,3,4])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move to end r = self.client.post(url, {'oldIndex':3, 'newIndex':5}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) + self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[1,2,3,4,5])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move beginning to end r = self.client.post(url, {'oldIndex':1, 'newIndex':5}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,4,5,1])) + self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,4,5,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Move middle to middle r = self.client.post(url, {'oldIndex':3, 'newIndex':4}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,5,4,1])) + self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,3,5,4,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() r = self.client.post(url, {'oldIndex':3, 'newIndex':2}) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()['success'],True) - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,5,3,4,1])) + self.assertEqual(list(session.presentations.order_by('order').values_list('pk',flat=True)),_sppk_at(sppk,[2,5,3,4,1])) + self.assertTrue(mock_slides_manager_cls.called) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertTrue(mock_slides_manager_cls.return_value.send_update.called) + self.assertEqual(mock_slides_manager_cls.return_value.send_update.call_args, call(session)) + mock_slides_manager_cls.reset_mock() # Reset for next iteration in the loop - session.sessionpresentation_set.update(order=F('pk')) + session.presentations.update(order=F('pk')) self.client.logout() @@ -3345,7 +3449,7 @@ def test_slide_order_reconditioning(self): except AssertionError: pass - self.assertEqual(list(session.sessionpresentation_set.order_by('order').values_list('order',flat=True)),list(range(1,6))) + self.assertEqual(list(session.presentations.order_by('order').values_list('order',flat=True)),list(range(1,6))) class EditTests(TestCase): @@ -4334,7 +4438,7 @@ def test_add_session_drafts(self): group.role_set.create(name_id='chair',person = group_chair, email = group_chair.email()) session = SessionFactory.create(meeting__type_id='ietf',group=group, meeting__date=date_today() + datetime.timedelta(days=90)) SessionPresentationFactory.create(session=session,document__type_id='draft',rev=None) - old_draft = session.sessionpresentation_set.filter(document__type='draft').first().document + old_draft = session.presentations.filter(document__type='draft').first().document new_draft = DocumentFactory(type_id='draft') url = urlreverse('ietf.meeting.views.add_session_drafts', kwargs=dict(num=session.meeting.number, session_id=session.pk)) @@ -4355,10 +4459,10 @@ def test_add_session_drafts(self): q = PyQuery(r.content) self.assertIn("Already linked:", q('form .text-danger').text()) - self.assertEqual(1,session.sessionpresentation_set.count()) + self.assertEqual(1,session.presentations.count()) r = self.client.post(url,dict(drafts=[new_draft.pk,])) self.assertTrue(r.status_code, 302) - self.assertEqual(2,session.sessionpresentation_set.count()) + self.assertEqual(2,session.presentations.count()) session.meeting.date -= datetime.timedelta(days=180) session.meeting.save() @@ -5595,8 +5699,17 @@ def test_interim_request_cancel(self, mock): self.assertEqual(r.status_code, 403) self.assertFalse(mock.called, 'Should not cancel sessions if request rejected') - # test cancelling before announcement + # test with overly-long comments + comments += '0123456789abcdef'*32 self.client.login(username="marschairman", password="marschairman+password") + r = self.client.post(url, {'comments': comments}) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(q('form .is-invalid')) + # truncate to max_length + comments = comments[:512] + + # test cancelling before announcement length_before = len(outbox) r = self.client.post(url, {'comments': comments}) self.assertRedirects(r, urlreverse('ietf.meeting.views.upcoming')) @@ -5618,6 +5731,7 @@ def test_interim_request_cancel(self, mock): self.assertEqual(session.agenda_note, comments) self.assertEqual(len(outbox), length_before + 1) self.assertIn('Interim Meeting Cancelled', outbox[-1]['Subject']) + self.assertIn(comments, get_payload_text(outbox[-1])) self.assertTrue(mock.called, 'Should cancel sessions if request handled') self.assertCountEqual(mock.call_args[0][1], meeting.session_set.all()) @@ -5972,7 +6086,7 @@ class FinalizeProceedingsTests(TestCase): def test_finalize_proceedings(self): make_meeting_test_data() meeting = Meeting.objects.filter(type_id='ietf').order_by('id').last() - meeting.session_set.filter(group__acronym='mars').first().sessionpresentation_set.create(document=Document.objects.filter(type='draft').first(),rev=None) + meeting.session_set.filter(group__acronym='mars').first().presentations.create(document=Document.objects.filter(type='draft').first(),rev=None) url = urlreverse('ietf.meeting.views.finalize_proceedings',kwargs={'num':meeting.number}) login_testing_unauthorized(self,"secretary",url) @@ -5980,13 +6094,41 @@ def test_finalize_proceedings(self): self.assertEqual(r.status_code, 200) self.assertEqual(meeting.proceedings_final,False) - self.assertEqual(meeting.session_set.filter(group__acronym="mars").first().sessionpresentation_set.filter(document__type="draft").first().rev,None) + self.assertEqual(meeting.session_set.filter(group__acronym="mars").first().presentations.filter(document__type="draft").first().rev,None) r = self.client.post(url,{'finalize':1}) self.assertEqual(r.status_code, 302) meeting = Meeting.objects.get(pk=meeting.pk) self.assertEqual(meeting.proceedings_final,True) - self.assertEqual(meeting.session_set.filter(group__acronym="mars").first().sessionpresentation_set.filter(document__type="draft").first().rev,'00') + self.assertEqual(meeting.session_set.filter(group__acronym="mars").first().presentations.filter(document__type="draft").first().rev,'00') + @patch("ietf.meeting.utils.generate_bluesheet") + def test_bluesheet_generation(self, mock): + meeting = MeetingFactory(type_id="ietf", number="107") # number where generate_bluesheets should not be called + SessionFactory.create_batch(5, meeting=meeting) + url = urlreverse("ietf.meeting.views.finalize_proceedings", kwargs={"num": meeting.number}) + self.client.login(username="secretary", password="secretary+password") + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertFalse(mock.called) + r = self.client.post(url,{'finalize': 1}) + self.assertEqual(r.status_code, 302) + self.assertFalse(mock.called) + + meeting = MeetingFactory(type_id="ietf", number="108") # number where generate_bluesheets should be called + SessionFactory.create_batch(5, meeting=meeting) + url = urlreverse("ietf.meeting.views.finalize_proceedings", kwargs={"num": meeting.number}) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertFalse(mock.called) + r = self.client.post(url,{'finalize': 1}) + self.assertEqual(r.status_code, 302) + self.assertTrue(mock.called) + self.assertCountEqual( + [call_args[0][1] for call_args in mock.call_args_list], + [sess for sess in meeting.session_set.all()], + ) + + class MaterialsTests(TestCase): settings_temp_path_overrides = TestCase.settings_temp_path_overrides + [ 'AGENDA_PATH', @@ -6027,12 +6169,12 @@ def test_upload_bluesheets(self): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertIn('Upload', str(q("title"))) - self.assertFalse(session.sessionpresentation_set.exists()) + self.assertFalse(session.presentations.exists()) test_file = StringIO('%PDF-1.4\n%âãÏÓ\nthis is some text for a test') test_file.name = "not_really.pdf" r = self.client.post(url,dict(file=test_file)) self.assertEqual(r.status_code, 302) - bs_doc = session.sessionpresentation_set.filter(document__type_id='bluesheets').first().document + bs_doc = session.presentations.filter(document__type_id='bluesheets').first().document self.assertEqual(bs_doc.rev,'00') r = self.client.get(url) self.assertEqual(r.status_code, 200) @@ -6062,12 +6204,12 @@ def test_upload_bluesheets_interim(self): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertIn('Upload', str(q("title"))) - self.assertFalse(session.sessionpresentation_set.exists()) + self.assertFalse(session.presentations.exists()) test_file = StringIO('%PDF-1.4\n%âãÏÓ\nthis is some text for a test') test_file.name = "not_really.pdf" r = self.client.post(url,dict(file=test_file)) self.assertEqual(r.status_code, 302) - bs_doc = session.sessionpresentation_set.filter(document__type_id='bluesheets').first().document + bs_doc = session.presentations.filter(document__type_id='bluesheets').first().document self.assertEqual(bs_doc.rev,'00') def test_upload_bluesheets_interim_chair_access(self): @@ -6095,7 +6237,7 @@ def test_upload_minutes_agenda(self): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertIn('Upload', str(q("Title"))) - self.assertFalse(session.sessionpresentation_set.exists()) + self.assertFalse(session.presentations.exists()) self.assertFalse(q('form input[type="checkbox"]')) session2 = SessionFactory(meeting=session.meeting,group=session.group) @@ -6106,21 +6248,21 @@ def test_upload_minutes_agenda(self): test_file = BytesIO(b'this is some text for a test') test_file.name = "not_really.json" - r = self.client.post(url,dict(file=test_file)) + r = self.client.post(url,dict(submission_method="upload",file=test_file)) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertTrue(q('form .is-invalid')) test_file = BytesIO(b'this is some text for a test'*1510000) test_file.name = "not_really.pdf" - r = self.client.post(url,dict(file=test_file)) + r = self.client.post(url,dict(submission_method="upload",file=test_file)) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertTrue(q('form .is-invalid')) test_file = BytesIO(b'') test_file.name = "not_really.html" - r = self.client.post(url,dict(file=test_file)) + r = self.client.post(url,dict(submission_method="upload",file=test_file)) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertTrue(q('form .is-invalid')) @@ -6128,9 +6270,9 @@ def test_upload_minutes_agenda(self): # Test html sanitization test_file = BytesIO(b'Title

Title

Some text
') test_file.name = "some.html" - r = self.client.post(url,dict(file=test_file)) + r = self.client.post(url,dict(submission_method="upload",file=test_file)) self.assertEqual(r.status_code, 302) - doc = session.sessionpresentation_set.filter(document__type_id=doctype).first().document + doc = session.presentations.filter(document__type_id=doctype).first().document self.assertEqual(doc.rev,'00') text = doc.text() self.assertIn('Some text', text) @@ -6140,11 +6282,11 @@ def test_upload_minutes_agenda(self): # txt upload test_file = BytesIO(b'This is some text for a test, with the word\nvirtual at the beginning of a line.') test_file.name = "some.txt" - r = self.client.post(url,dict(file=test_file,apply_to_all=False)) + r = self.client.post(url,dict(submission_method="upload",file=test_file,apply_to_all=False)) self.assertEqual(r.status_code, 302) - doc = session.sessionpresentation_set.filter(document__type_id=doctype).first().document + doc = session.presentations.filter(document__type_id=doctype).first().document self.assertEqual(doc.rev,'01') - self.assertFalse(session2.sessionpresentation_set.filter(document__type_id=doctype)) + self.assertFalse(session2.presentations.filter(document__type_id=doctype)) r = self.client.get(url) self.assertEqual(r.status_code, 200) @@ -6152,16 +6294,16 @@ def test_upload_minutes_agenda(self): self.assertIn('Revise', str(q("Title"))) test_file = BytesIO(b'this is some different text for a test') test_file.name = "also_some.txt" - r = self.client.post(url,dict(file=test_file,apply_to_all=True)) + r = self.client.post(url,dict(submission_method="upload",file=test_file,apply_to_all=True)) self.assertEqual(r.status_code, 302) doc = Document.objects.get(pk=doc.pk) self.assertEqual(doc.rev,'02') - self.assertTrue(session2.sessionpresentation_set.filter(document__type_id=doctype)) + self.assertTrue(session2.presentations.filter(document__type_id=doctype)) # Test bad encoding test_file = BytesIO('

Title

Some\x93text
'.encode('latin1')) test_file.name = "some.html" - r = self.client.post(url,dict(file=test_file)) + r = self.client.post(url,dict(submission_method="upload",file=test_file)) self.assertContains(r, 'Could not identify the file encoding') doc = Document.objects.get(pk=doc.pk) self.assertEqual(doc.rev,'02') @@ -6186,12 +6328,12 @@ def test_upload_minutes_agenda_unscheduled(self): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertIn('Upload', str(q("Title"))) - self.assertFalse(session.sessionpresentation_set.exists()) + self.assertFalse(session.presentations.exists()) self.assertFalse(q('form input[type="checkbox"]')) test_file = BytesIO(b'this is some text for a test') test_file.name = "not_really.txt" - r = self.client.post(url,dict(file=test_file,apply_to_all=False)) + r = self.client.post(url,dict(submission_method="upload",file=test_file,apply_to_all=False)) self.assertEqual(r.status_code, 410) @override_settings(MEETING_MATERIALS_SERVE_LOCALLY=True) @@ -6208,12 +6350,12 @@ def test_upload_minutes_agenda_interim(self): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertIn('Upload', str(q("title"))) - self.assertFalse(session.sessionpresentation_set.filter(document__type_id=doctype)) + self.assertFalse(session.presentations.filter(document__type_id=doctype)) test_file = BytesIO(b'this is some text for a test') test_file.name = "not_really.txt" - r = self.client.post(url,dict(file=test_file)) + r = self.client.post(url,dict(submission_method="upload",file=test_file)) self.assertEqual(r.status_code, 302) - doc = session.sessionpresentation_set.filter(document__type_id=doctype).first().document + doc = session.presentations.filter(document__type_id=doctype).first().document self.assertEqual(doc.rev,'00') # Verify that we don't have dead links @@ -6223,7 +6365,76 @@ def test_upload_minutes_agenda_interim(self): self.requests_mock.get(f'{session.notes_url()}/info', text=json.dumps({'title': 'title', 'updatetime': '2021-12-01T17:11:00z'})) self.crawl_materials(url=url, top=top) - def test_upload_slides(self): + @override_settings(MEETING_MATERIALS_SERVE_LOCALLY=True) + def test_upload_narrativeminutes(self): + for type_id in ["interim","ietf"]: + session=SessionFactory(meeting__type_id=type_id,group__acronym='iesg') + doctype='narrativeminutes' + url = urlreverse('ietf.meeting.views.upload_session_narrativeminutes',kwargs={'num':session.meeting.number,'session_id':session.id}) + self.client.logout() + login_testing_unauthorized(self,"secretary",url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertIn('Upload', str(q("title"))) + self.assertFalse(session.presentations.filter(document__type_id=doctype)) + test_file = BytesIO(b'this is some text for a test') + test_file.name = "not_really.txt" + r = self.client.post(url,dict(submission_method="upload",file=test_file)) + self.assertEqual(r.status_code, 302) + doc = session.presentations.filter(document__type_id=doctype).first().document + self.assertEqual(doc.rev,'00') + + # Verify that we don't have dead links + url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':session.meeting.number, 'acronym': session.group.acronym}) + top = '/meeting/%s/' % session.meeting.number + self.requests_mock.get(f'{session.notes_url()}/download', text='markdown notes') + self.requests_mock.get(f'{session.notes_url()}/info', text=json.dumps({'title': 'title', 'updatetime': '2021-12-01T17:11:00z'})) + self.crawl_materials(url=url, top=top) + + def test_enter_agenda(self): + session = SessionFactory(meeting__type_id='ietf') + url = urlreverse('ietf.meeting.views.upload_session_agenda',kwargs={'num':session.meeting.number,'session_id':session.id}) + redirect_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':session.meeting.number,'acronym':session.group.acronym}) + login_testing_unauthorized(self,"secretary",url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertIn('Upload', str(q("Title"))) + self.assertFalse(session.presentations.exists()) + + test_text = 'Enter agenda from scratch' + r = self.client.post(url,dict(submission_method="enter",content=test_text)) + self.assertRedirects(r, redirect_url) + doc = session.presentations.filter(document__type_id='agenda').first().document + self.assertEqual(doc.rev,'00') + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertIn('Revise', str(q("Title"))) + + test_file = BytesIO(b'Upload after enter') + test_file.name = "some.txt" + r = self.client.post(url,dict(submission_method="upload",file=test_file)) + self.assertRedirects(r, redirect_url) + doc = Document.objects.get(pk=doc.pk) + self.assertEqual(doc.rev,'01') + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertIn('Revise', str(q("Title"))) + + test_text = 'Enter after upload' + r = self.client.post(url,dict(submission_method="enter",content=test_text)) + self.assertRedirects(r, redirect_url) + doc = Document.objects.get(pk=doc.pk) + self.assertEqual(doc.rev,'02') + + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_upload_slides(self, mock_slides_manager_cls): session1 = SessionFactory(meeting__type_id='ietf') session2 = SessionFactory(meeting=session1.meeting,group=session1.group) @@ -6231,32 +6442,53 @@ def test_upload_slides(self): login_testing_unauthorized(self,"secretary",url) r = self.client.get(url) self.assertEqual(r.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) q = PyQuery(r.content) self.assertIn('Upload', str(q("title"))) - self.assertFalse(session1.sessionpresentation_set.filter(document__type_id='slides')) + self.assertFalse(session1.presentations.filter(document__type_id='slides')) test_file = BytesIO(b'this is not really a slide') test_file.name = 'not_really.txt' r = self.client.post(url,dict(file=test_file,title='a test slide file',apply_to_all=True)) self.assertEqual(r.status_code, 302) - self.assertEqual(session1.sessionpresentation_set.count(),1) - self.assertEqual(session2.sessionpresentation_set.count(),1) - sp = session2.sessionpresentation_set.first() + self.assertEqual(session1.presentations.count(),1) + self.assertEqual(session2.presentations.count(),1) + sp = session2.presentations.first() self.assertEqual(sp.document.name, 'slides-%s-%s-a-test-slide-file' % (session1.meeting.number,session1.group.acronym ) ) self.assertEqual(sp.order,1) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 2) + # don't care which order they were called in, just that both sessions were updated + self.assertCountEqual( + mock_slides_manager_cls.return_value.add.call_args_list, + [ + call(session=session1, slides=sp.document, order=1), + call(session=session2, slides=sp.document, order=1), + ], + ) + mock_slides_manager_cls.reset_mock() url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id}) test_file = BytesIO(b'some other thing still not slidelike') test_file.name = 'also_not_really.txt' r = self.client.post(url,dict(file=test_file,title='a different slide file',apply_to_all=False)) self.assertEqual(r.status_code, 302) - self.assertEqual(session1.sessionpresentation_set.count(),1) - self.assertEqual(session2.sessionpresentation_set.count(),2) - sp = session2.sessionpresentation_set.get(document__name__endswith='-a-different-slide-file') + self.assertEqual(session1.presentations.count(),1) + self.assertEqual(session2.presentations.count(),2) + sp = session2.presentations.get(document__name__endswith='-a-different-slide-file') self.assertEqual(sp.order,2) self.assertEqual(sp.rev,'00') self.assertEqual(sp.document.rev,'00') + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.add.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.add.call_args, + call(session=session2, slides=sp.document, order=2), + ) + mock_slides_manager_cls.reset_mock() - url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id,'name':session2.sessionpresentation_set.get(order=2).document.name}) + url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session2.meeting.number,'session_id':session2.id,'name':session2.presentations.get(order=2).document.name}) r = self.client.get(url) self.assertTrue(r.status_code, 200) q = PyQuery(r.content) @@ -6265,12 +6497,19 @@ def test_upload_slides(self): test_file.name = 'doesnotmatter.txt' r = self.client.post(url,dict(file=test_file,title='rename the presentation',apply_to_all=False)) self.assertEqual(r.status_code, 302) - self.assertEqual(session1.sessionpresentation_set.count(),1) - self.assertEqual(session2.sessionpresentation_set.count(),2) - sp = session2.sessionpresentation_set.get(order=2) - self.assertEqual(sp.rev,'01') - self.assertEqual(sp.document.rev,'01') - + self.assertEqual(session1.presentations.count(),1) + self.assertEqual(session2.presentations.count(),2) + replacement_sp = session2.presentations.get(order=2) + self.assertEqual(replacement_sp.rev,'01') + self.assertEqual(replacement_sp.document.rev,'01') + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.revise.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.revise.call_args, + call(session=session2, slides=sp.document), + ) + def test_upload_slide_title_bad_unicode(self): session1 = SessionFactory(meeting__type_id='ietf') url = urlreverse('ietf.meeting.views.upload_session_slides',kwargs={'num':session1.meeting.number,'session_id':session1.id}) @@ -6279,7 +6518,7 @@ def test_upload_slide_title_bad_unicode(self): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertIn('Upload', str(q("title"))) - self.assertFalse(session1.sessionpresentation_set.filter(document__type_id='slides')) + self.assertFalse(session1.presentations.filter(document__type_id='slides')) test_file = BytesIO(b'this is not really a slide') test_file.name = 'not_really.txt' r = self.client.post(url,dict(file=test_file,title='title with bad character \U0001fabc ')) @@ -6288,29 +6527,61 @@ def test_upload_slide_title_bad_unicode(self): self.assertTrue(q('form .is-invalid')) self.assertIn("Unicode BMP", q('form .is-invalid div').text()) - def test_remove_sessionpresentation(self): + @override_settings(MEETECHO_API_CONFIG="fake settings") # enough to trigger API calls + @patch("ietf.meeting.views.SlidesManager") + def test_remove_sessionpresentation(self, mock_slides_manager_cls): session = SessionFactory(meeting__type_id='ietf') + agenda = DocumentFactory(type_id='agenda') doc = DocumentFactory(type_id='slides') - session.sessionpresentation_set.create(document=doc) + session.presentations.create(document=agenda) + session.presentations.create(document=doc) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':session.id,'name':'no-such-doc'}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':0,'name':doc.name}) response = self.client.get(url) self.assertEqual(response.status_code, 404) + self.assertFalse(mock_slides_manager_cls.called) url = urlreverse('ietf.meeting.views.remove_sessionpresentation',kwargs={'num':session.meeting.number,'session_id':session.id,'name':doc.name}) login_testing_unauthorized(self,"secretary",url) response = self.client.get(url) self.assertEqual(response.status_code, 200) + self.assertFalse(mock_slides_manager_cls.called) - self.assertEqual(1,session.sessionpresentation_set.count()) + # Removing slides should remove the materials and call MeetechoAPI + self.assertEqual(2, session.presentations.count()) response = self.client.post(url,{'remove_session':''}) self.assertEqual(response.status_code, 302) - self.assertEqual(0,session.sessionpresentation_set.count()) - self.assertEqual(2,doc.docevent_set.count()) + self.assertEqual(1, session.presentations.count()) + self.assertEqual(2, doc.docevent_set.count()) + self.assertEqual(mock_slides_manager_cls.call_count, 1) + self.assertEqual(mock_slides_manager_cls.call_args, call(api_config="fake settings")) + self.assertEqual(mock_slides_manager_cls.return_value.delete.call_count, 1) + self.assertEqual( + mock_slides_manager_cls.return_value.delete.call_args, + call(session=session, slides=doc), + ) + mock_slides_manager_cls.reset_mock() + + # Removing non-slides should only remove the materials + url = urlreverse( + "ietf.meeting.views.remove_sessionpresentation", + kwargs={ + "num": session.meeting.number, + "session_id": session.id, + "name": agenda.name, + }, + ) + response = self.client.post(url, {"remove_session" : ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(0, session.presentations.count()) + self.assertEqual(2, agenda.docevent_set.count()) + self.assertFalse(mock_slides_manager_cls.called) + def test_propose_session_slides(self): for type_id in ['ietf','interim']: @@ -6398,8 +6669,8 @@ def test_approve_proposed_slides(self): submission = SlideSubmission.objects.get(id = submission.id) self.assertEqual(submission.status_id, 'approved') self.assertIsNotNone(submission.doc) - self.assertEqual(session.sessionpresentation_set.count(),1) - self.assertEqual(session.sessionpresentation_set.first().document.title,'different title') + self.assertEqual(session.presentations.count(),1) + self.assertEqual(session.presentations.first().document.title,'different title') r = self.client.get(url) self.assertEqual(r.status_code, 200) self.assertRegex(r.content.decode(), r"These\s+slides\s+have\s+already\s+been\s+approved") @@ -6421,8 +6692,8 @@ def test_approve_proposed_slides_multisession_apply_one(self): self.assertTrue(q('#id_apply_to_all')) r = self.client.post(url,dict(title='yet another title',approve='approve')) self.assertEqual(r.status_code,302) - self.assertEqual(session1.sessionpresentation_set.count(),1) - self.assertEqual(session2.sessionpresentation_set.count(),0) + self.assertEqual(session1.presentations.count(),1) + self.assertEqual(session2.presentations.count(),0) def test_approve_proposed_slides_multisession_apply_all(self): submission = SlideSubmissionFactory(session__meeting__type_id='ietf') @@ -6436,8 +6707,8 @@ def test_approve_proposed_slides_multisession_apply_all(self): self.assertEqual(r.status_code,200) r = self.client.post(url,dict(title='yet another title',apply_to_all=1,approve='approve')) self.assertEqual(r.status_code,302) - self.assertEqual(session1.sessionpresentation_set.count(),1) - self.assertEqual(session2.sessionpresentation_set.count(),1) + self.assertEqual(session1.presentations.count(),1) + self.assertEqual(session2.presentations.count(),1) def test_submit_and_approve_multiple_versions(self): session = SessionFactory(meeting__type_id='ietf') @@ -6462,7 +6733,7 @@ def test_submit_and_approve_multiple_versions(self): self.assertEqual(r.status_code,302) self.client.logout() - self.assertEqual(session.sessionpresentation_set.first().document.rev,'00') + self.assertEqual(session.presentations.first().document.rev,'00') login_testing_unauthorized(self,newperson.user.username,propose_url) test_file = BytesIO(b'this is not really a slide, but it is another version of it') @@ -6490,9 +6761,9 @@ def test_submit_and_approve_multiple_versions(self): self.assertEqual(SlideSubmission.objects.filter(status__slug = 'pending').count(),0) self.assertEqual(SlideSubmission.objects.filter(status__slug = 'rejected').count(),1) - self.assertEqual(session.sessionpresentation_set.first().document.rev,'01') + self.assertEqual(session.presentations.first().document.rev,'01') path = os.path.join(submission.session.meeting.get_materials_path(),'slides') - filename = os.path.join(path,session.sessionpresentation_set.first().document.name+'-01.txt') + filename = os.path.join(path,session.presentations.first().document.name+'-01.txt') self.assertTrue(os.path.exists(filename)) fd = io.open(filename, 'r') contents = fd.read() @@ -6609,7 +6880,7 @@ def test_allows_import_on_existing_bad_unicode(self): self.client.login(username='secretary', password='secretary+password') r = self.client.post(url, {'markdown_text': 'replaced below'}) # create a rev with open( - self.session.sessionpresentation_set.filter(document__type="minutes").first().document.get_file_name(), + self.session.presentations.filter(document__type="minutes").first().document.get_file_name(), 'wb' ) as f: # Replace existing content with an invalid Unicode byte string. The particular invalid @@ -6634,7 +6905,7 @@ def test_handles_missing_previous_revision_file(self): self.client.login(username='secretary', password='secretary+password') r = self.client.post(url, {'markdown_text': 'original markdown text'}) # create a rev # remove the file uploaded for the first rev - minutes_docs = self.session.sessionpresentation_set.filter(document__type='minutes') + minutes_docs = self.session.presentations.filter(document__type='minutes') self.assertEqual(minutes_docs.count(), 1) Path(minutes_docs.first().document.get_file_name()).unlink() @@ -7734,7 +8005,7 @@ def test_proceedings(self): def test_named_session(self): """Session with a name should appear separately in the proceedings""" - meeting = MeetingFactory(type_id='ietf', number='100') + meeting = MeetingFactory(type_id='ietf', number='100', proceedings_final=True) group = GroupFactory() plain_session = SessionFactory(meeting=meeting, group=group) named_session = SessionFactory(meeting=meeting, group=group, name='I Got a Name') @@ -7769,7 +8040,7 @@ def test_named_session(self): named_row = named_label.closest('tr') self.assertTrue(named_row) - for material in (sp.document for sp in plain_session.sessionpresentation_set.all()): + for material in (sp.document for sp in plain_session.presentations.all()): if material.type_id == 'draft': expected_url = urlreverse( 'ietf.doc.views_doc.document_main', @@ -7780,7 +8051,7 @@ def test_named_session(self): self.assertTrue(plain_row.find(f'a[href="{expected_url}"]')) self.assertFalse(named_row.find(f'a[href="{expected_url}"]')) - for material in (sp.document for sp in named_session.sessionpresentation_set.all()): + for material in (sp.document for sp in named_session.presentations.all()): if material.type_id == 'draft': expected_url = urlreverse( 'ietf.doc.views_doc.document_main', @@ -7855,7 +8126,6 @@ def test_proceedings_attendees(self): - prefer onsite checkedin=True to remote attended when same person has both """ - make_meeting_test_data() meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number="118") person_a = PersonFactory(name='Person A') person_b = PersonFactory(name='Person B') @@ -7880,9 +8150,14 @@ def test_proceedings_overview(self): '''Test proceedings IETF Overview page. Note: old meetings aren't supported so need to add a new meeting then test. ''' - make_meeting_test_data() - meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97") - finalize(meeting) + meeting = make_meeting_test_data(meeting=MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97")) + + # finalize meeting + url = urlreverse('ietf.meeting.views.finalize_proceedings',kwargs={'num':meeting.number}) + login_testing_unauthorized(self,"secretary",url) + r = self.client.post(url,{'finalize':1}) + self.assertEqual(r.status_code, 302) + url = urlreverse('ietf.meeting.views.proceedings_overview',kwargs={'num':97}) response = self.client.get(url) self.assertContains(response, 'The Internet Engineering Task Force') @@ -8285,3 +8560,127 @@ def test_participants_for_meeting(self): self.assertTrue(person_b.pk not in checked_in) self.assertTrue(person_c.pk in attended) self.assertTrue(person_d.pk not in attended) + + def test_session_attendance(self): + meeting = MeetingFactory(type_id='ietf', date=datetime.date(2023, 11, 4), number='118') + make_meeting_test_data(meeting=meeting) + session = Session.objects.filter(meeting=meeting, group__acronym='mars').first() + regs = MeetingRegistrationFactory.create_batch(3, meeting=meeting) + persons = [reg.person for reg in regs] + self.assertEqual(session.attended_set.count(), 0) + + # If there are no attendees, the link isn't offered, and getting + # the page directly returns an empty list. + session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) + attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + r = self.client.get(session_url) + self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '0 attendees') + + # Add some attendees + add_attendees_url = urlreverse('ietf.meeting.views.api_add_session_attendees') + recmanrole = RoleFactory(group__type_id='ietf', name_id='recman', person__user__last_login=timezone.now()) + recman = recmanrole.person + apikey = PersonalApiKey.objects.create(endpoint=add_attendees_url, person=recman) + attendees = [person.user.pk for person in persons] + self.client.login(username='recman', password='recman+password') + r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(session.attended_set.count(), 3) + + # Before a meeting is finalized, session_attendance renders a live + # view of the Attended records for the session. + r = self.client.get(session_url) + self.assertContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '3 attendees') + for person in persons: + self.assertContains(r, person.plain_name()) + + # Test for the "I was there" button. + def _test_button(person, expected): + username = person.user.username + self.client.login(username=username, password=f'{username}+password') + r = self.client.get(attendance_url) + self.assertEqual(b"I was there" in r.content, expected) + # recman isn't registered for the meeting + _test_button(recman, False) + # person0 is already on the bluesheet + _test_button(persons[0], False) + # person3 attests he was there + persons.append(MeetingRegistrationFactory(meeting=meeting).person) + # button isn't shown if we're outside the corrections windows + meeting.importantdate_set.create(name_id='revsub',date=date_today() - datetime.timedelta(days=20)) + _test_button(persons[3], False) + # attempt to POST anyway is ignored + r = self.client.post(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, persons[3].plain_name()) + self.assertEqual(session.attended_set.count(), 3) + # button is shown, and POST is accepted + meeting.importantdate_set.update(name_id='revsub',date=date_today() + datetime.timedelta(days=20)) + _test_button(persons[3], True) + r = self.client.post(attendance_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, persons[3].plain_name()) + self.assertEqual(session.attended_set.count(), 4) + + # When the meeting is finalized, a bluesheet file is generated, + # and session_attendance redirects to the file. + self.client.login(username='secretary',password='secretary+password') + finalize_url = urlreverse('ietf.meeting.views.finalize_proceedings', kwargs={'num':meeting.number}) + r = self.client.post(finalize_url, {'finalize':1}) + self.assertRedirects(r, urlreverse('ietf.meeting.views.proceedings', kwargs={'num':meeting.number})) + doc = session.presentations.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + text = doc.text() + self.assertIn('4 attendees', text) + for person in persons: + self.assertIn(person.plain_name(), text) + r = self.client.get(session_url) + self.assertContains(r, doc.get_href()) + self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) + + # An interim meeting is considered finalized immediately. + meeting = make_interim_meeting(group=GroupFactory(acronym='mars'), date=date_today()) + session = Session.objects.filter(meeting=meeting, group__acronym='mars').first() + attendance_url = urlreverse('ietf.meeting.views.session_attendance', kwargs={'num':meeting.number, 'session_id':session.id}) + self.assertEqual(session.attended_set.count(), 0) + self.client.login(username='recman', password='recman+password') + attendees = [person.user.pk for person in persons] + r = self.client.post(add_attendees_url, {'apikey':apikey.hash(), 'attended':f'{{"session_id":{session.pk},"attendees":{attendees}}}'}) + self.assertEqual(r.status_code, 200) + self.assertEqual(session.attended_set.count(), 4) + doc = session.presentations.filter(document__type_id='bluesheets').first().document + self.assertEqual(doc.rev,'00') + session_url = urlreverse('ietf.meeting.views.session_details', kwargs={'num':meeting.number, 'acronym':session.group.acronym}) + r = self.client.get(session_url) + self.assertContains(r, doc.get_href()) + self.assertNotContains(r, attendance_url) + r = self.client.get(attendance_url) + self.assertEqual(r.status_code,302) + self.assertEqual(r['Location'],doc.get_href()) + + def test_bluesheet_data(self): + session = SessionFactory(meeting__type_id="ietf") + attended_with_affil = MeetingRegistrationFactory(meeting=session.meeting, affiliation="Somewhere") + AttendedFactory(session=session, person=attended_with_affil.person, time="2023-03-13T01:24:00Z") # joined 2nd + attended_no_affil = MeetingRegistrationFactory(meeting=session.meeting) + AttendedFactory(session=session, person=attended_no_affil.person, time="2023-03-13T01:23:00Z") # joined 1st + MeetingRegistrationFactory(meeting=session.meeting) # did not attend + + data = bluesheet_data(session) + self.assertEqual( + data, + [ + {"name": attended_no_affil.person.plain_name(), "affiliation": ""}, + {"name": attended_with_affil.person.plain_name(), "affiliation": "Somewhere"}, + ] + ) + diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index d7a623899e..26d3d93b20 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2020, All Rights Reserved +# Copyright The IETF Trust 2007-2024, All Rights Reserved from django.conf import settings from django.urls import include @@ -16,8 +16,10 @@ def get_redirect_url(self, *args, **kwargs): safe_for_all_meeting_types = [ url(r'^session/(?P[-a-z0-9]+)/?$', views.session_details), url(r'^session/(?P\d+)/drafts$', views.add_session_drafts), + url(r'^session/(?P\d+)/attendance$', views.session_attendance), url(r'^session/(?P\d+)/bluesheets$', views.upload_session_bluesheets), url(r'^session/(?P\d+)/minutes$', views.upload_session_minutes), + url(r'^session/(?P\d+)/narrativeminutes$', views.upload_session_narrativeminutes), url(r'^session/(?P\d+)/agenda$', views.upload_session_agenda), url(r'^session/(?P\d+)/import/minutes$', views.import_session_minutes), url(r'^session/(?P\d+)/propose_slides$', views.propose_session_slides), @@ -79,7 +81,6 @@ def get_redirect_url(self, *args, **kwargs): url(r'^agenda.json$', views.agenda_json), url(r'^agenda/week-view(?:.html)?/?$', AgendaRedirectView.as_view(pattern_name='agenda', permanent=True)), url(r'^floor-plan/?$', views.agenda, name='floor-plan'), - url(r'^floor-plan/(?P[-a-z0-9_]+)/?$', RedirectView.as_view(pattern_name='floor-plan', permanent=True)), url(r'^week-view(?:.html)?/?$', AgendaRedirectView.as_view(pattern_name='agenda', permanent=True)), url(r'^materials(?:.html)?/?$', views.materials), url(r'^request_minutes/?$', views.request_minutes), diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 416e9c61fe..8d44cb7c17 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -1,10 +1,11 @@ -# Copyright The IETF Trust 2016-2020, All Rights Reserved +# Copyright The IETF Trust 2016-2024, All Rights Reserved # -*- coding: utf-8 -*- import datetime import itertools import os import pytz import subprocess +import tempfile from collections import defaultdict from pathlib import Path @@ -12,6 +13,7 @@ from django.conf import settings from django.contrib import messages from django.db.models import Q +from django.template.loader import render_to_string from django.utils import timezone from django.utils.encoding import smart_str @@ -26,13 +28,17 @@ from ietf.group.utils import can_manage_materials from ietf.name.models import SessionStatusName, ConstraintName, DocTypeName from ietf.person.models import Person +from ietf.stats.models import MeetingRegistration from ietf.utils.html import sanitize_document from ietf.utils.log import log from ietf.utils.timezone import date_today def session_time_for_sorting(session, use_meeting_date): - official_timeslot = TimeSlot.objects.filter(sessionassignments__session=session, sessionassignments__schedule__in=[session.meeting.schedule, session.meeting.schedule.base if session.meeting.schedule else None]).first() + if hasattr(session, "_otsa"): + official_timeslot=session._otsa.timeslot + else: + official_timeslot = TimeSlot.objects.filter(sessionassignments__session=session, sessionassignments__schedule__in=[session.meeting.schedule, session.meeting.schedule.base if session.meeting.schedule else None]).first() if official_timeslot: return official_timeslot.time elif use_meeting_date and session.meeting.date: @@ -75,13 +81,14 @@ def group_sessions(sessions): in_progress = [] recent = [] past = [] + for s in sessions: today = date_today(s.meeting.tz()) if s.meeting.date > today: future.append(s) elif s.meeting.end_date() >= today: in_progress.append(s) - elif not s.is_material_submission_cutoff(): + elif not getattr(s, "cached_is_cutoff", lambda: s.is_material_submission_cutoff): recent.append(s) else: past.append(s) @@ -91,6 +98,7 @@ def group_sessions(sessions): recent.reverse() past.reverse() + return future, in_progress, recent, past def get_upcoming_manageable_sessions(user): @@ -139,7 +147,84 @@ def create_proceedings_templates(meeting): meeting.overview = template meeting.save() -def finalize(meeting): + +def bluesheet_data(session): + def affiliation(meeting, person): + # from OidcExtraScopeClaims.scope_registration() + email_list = person.email_set.values_list("address") + q = Q(person=person, meeting=meeting) | Q(email__in=email_list, meeting=meeting) + reg = MeetingRegistration.objects.filter(q).exclude(affiliation="").first() + return reg.affiliation if reg else "" + + attendance = Attended.objects.filter(session=session).order_by("time") + meeting = session.meeting + return [ + { + "name": attended.person.plain_name(), + "affiliation": affiliation(meeting, attended.person), + } + for attended in attendance + ] + + +def save_bluesheet(request, session, file, encoding='utf-8'): + bluesheet_sp = session.presentations.filter(document__type='bluesheets').first() + _, ext = os.path.splitext(file.name) + + if bluesheet_sp: + doc = bluesheet_sp.document + doc.rev = '%02d' % (int(doc.rev)+1) + bluesheet_sp.rev = doc.rev + bluesheet_sp.save() + else: + ota = session.official_timeslotassignment() + sess_time = ota and ota.timeslot.time + + if session.meeting.type_id=='ietf': + name = 'bluesheets-%s-%s-%s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%Y%m%d%H%M")) + title = 'Bluesheets IETF%s: %s : %s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%a %H:%M")) + else: + name = 'bluesheets-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M")) + title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) + doc = Document.objects.create( + name = name, + type_id = 'bluesheets', + title = title, + group = session.group, + rev = '00', + ) + doc.states.add(State.objects.get(type_id='bluesheets',slug='active')) + session.presentations.create(document=doc,rev='00') + filename = '%s-%s%s'% ( doc.name, doc.rev, ext) + doc.uploaded_filename = filename + e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev) + save_error = handle_upload_file(file, filename, session.meeting, 'bluesheets', request=request, encoding=encoding) + if not save_error: + doc.save_with_history([e]) + return save_error + + +def generate_bluesheet(request, session): + data = bluesheet_data(session) + if not data: + return + text = render_to_string('meeting/bluesheet.txt', { + 'session': session, + 'data': data, + }) + fd, name = tempfile.mkstemp(suffix=".txt", text=True) + os.close(fd) + with open(name, "w") as file: + file.write(text) + with open(name, "br") as file: + return save_bluesheet(request, session, file) + + +def finalize(request, meeting): end_date = meeting.end_date() end_time = meeting.tz().localize( datetime.datetime.combine( @@ -148,13 +233,19 @@ def finalize(meeting): ) ).astimezone(pytz.utc) + datetime.timedelta(days=1) for session in meeting.session_set.all(): - for sp in session.sessionpresentation_set.filter(document__type='draft',rev=None): + for sp in session.presentations.filter(document__type='draft',rev=None): rev_before_end = [e for e in sp.document.docevent_set.filter(newrevisiondocevent__isnull=False).order_by('-time') if e.time <= end_time ] if rev_before_end: sp.rev = rev_before_end[-1].newrevisiondocevent.rev else: sp.rev = '00' sp.save() + + # Don't try to generate a bluesheet if it's before we had Attended records. + if int(meeting.number) >= 108: + save_error = generate_bluesheet(request, session) + if save_error: + messages.error(request, save_error) create_proceedings_templates(meeting) meeting.proceedings_final = True @@ -180,7 +271,7 @@ def sort_accept_tuple(accept): return tup def condition_slide_order(session): - qs = session.sessionpresentation_set.filter(document__type_id='slides').order_by('order') + qs = session.presentations.filter(document__type_id='slides').order_by('order') order_list = qs.values_list('order',flat=True) if list(order_list) != list(range(1,qs.count()+1)): for num, sp in enumerate(qs, start=1): @@ -550,7 +641,7 @@ class SaveMaterialsError(Exception): pass -def save_session_minutes_revision(session, file, ext, request, encoding=None, apply_to_all=False): +def save_session_minutes_revision(session, file, ext, request, encoding=None, apply_to_all=False, narrative=False): """Creates or updates session minutes records This updates the database models to reflect a new version. It does not handle @@ -563,7 +654,8 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap Returns (Document, [DocEvents]), which should be passed to doc.save_with_history() if the file contents are stored successfully. """ - minutes_sp = session.sessionpresentation_set.filter(document__type='minutes').first() + document_type = DocTypeName.objects.get(slug= 'narrativeminutes' if narrative else 'minutes') + minutes_sp = session.presentations.filter(document__type=document_type).first() if minutes_sp: doc = minutes_sp.document doc.rev = '%02d' % (int(doc.rev)+1) @@ -575,39 +667,37 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap if not sess_time: raise SessionNotScheduledError if session.meeting.type_id=='ietf': - name = 'minutes-%s-%s' % (session.meeting.number, - session.group.acronym) - title = 'Minutes IETF%s: %s' % (session.meeting.number, - session.group.acronym) + name = f"{document_type.prefix}-{session.meeting.number}-{session.group.acronym}" + title = f"{document_type.name} IETF{session.meeting.number}: {session.group.acronym}" if not apply_to_all: name += '-%s' % (sess_time.strftime("%Y%m%d%H%M"),) title += ': %s' % (sess_time.strftime("%a %H:%M"),) else: - name = 'minutes-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M")) - title = 'Minutes %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) + name =f"{document_type.prefix}-{session.meeting.number}-{sess_time.strftime('%Y%m%d%H%M')}" + title = f"{document_type.name} {session.meeting.number}: {sess_time.strftime('%a %H:%M')}" if Document.objects.filter(name=name).exists(): doc = Document.objects.get(name=name) doc.rev = '%02d' % (int(doc.rev)+1) else: doc = Document.objects.create( name = name, - type_id = 'minutes', + type = document_type, title = title, group = session.group, rev = '00', ) - doc.states.add(State.objects.get(type_id='minutes',slug='active')) - if session.sessionpresentation_set.filter(document=doc).exists(): - sp = session.sessionpresentation_set.get(document=doc) + doc.states.add(State.objects.get(type_id=document_type.slug,slug='active')) + if session.presentations.filter(document=doc).exists(): + sp = session.presentations.get(document=doc) sp.rev = doc.rev sp.save() else: - session.sessionpresentation_set.create(document=doc,rev=doc.rev) + session.presentations.create(document=doc,rev=doc.rev) if apply_to_all: for other_session in get_meeting_sessions(session.meeting.number, session.group.acronym): if other_session != session: - other_session.sessionpresentation_set.filter(document__type='minutes').delete() - other_session.sessionpresentation_set.create(document=doc,rev=doc.rev) + other_session.presentations.filter(document__type=document_type).delete() + other_session.presentations.create(document=doc,rev=doc.rev) filename = f'{doc.name}-{doc.rev}{ext}' doc.uploaded_filename = filename e = NewRevisionDocEvent.objects.create( @@ -623,7 +713,7 @@ def save_session_minutes_revision(session, file, ext, request, encoding=None, ap file=file, filename=doc.uploaded_filename, meeting=session.meeting, - subdir='minutes', + subdir=document_type.slug, request=request, encoding=encoding, ) @@ -719,7 +809,7 @@ def new_doc_for_session(type_id, session): rev = '00', ) doc.states.add(State.objects.get(type_id=type_id, slug='active')) - session.sessionpresentation_set.create(document=doc,rev='00') + session.presentations.create(document=doc,rev='00') return doc def write_doc_for_session(session, type_id, filename, contents): @@ -760,7 +850,7 @@ def create_recording(session, url, title=None, user=None): desc='New revision available', time=doc.time) pres = SessionPresentation.objects.create(session=session,document=doc,rev=doc.rev) - session.sessionpresentation_set.add(pres) + session.presentations.add(pres) return doc diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index ab39266396..5bb1a922b3 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2022, All Rights Reserved +# Copyright The IETF Trust 2007-2024, All Rights Reserved # -*- coding: utf-8 -*- @@ -19,6 +19,7 @@ from calendar import timegm from collections import OrderedDict, Counter, deque, defaultdict, namedtuple from functools import partialmethod +import jsonschema from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit from tempfile import mkstemp from wsgiref.handlers import format_date_time @@ -54,7 +55,7 @@ from ietf.person.models import Person, User from ietf.ietfauth.utils import role_required, has_role, user_is_person from ietf.mailtrigger.utils import gather_address_lists -from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission +from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission, Attended from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm, ImportMinutesForm, TimeSlotCreateForm, TimeSlotEditForm, SessionCancelForm, SessionEditForm ) @@ -84,14 +85,15 @@ from ietf.meeting.utils import preprocess_meeting_important_dates from ietf.meeting.utils import new_doc_for_session, write_doc_for_session from ietf.meeting.utils import get_activity_stats, post_process, create_recording -from ietf.meeting.utils import participants_for_meeting +from ietf.meeting.utils import participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet from ietf.message.utils import infer_message from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName from ietf.stats.models import MeetingRegistration from ietf.utils import markdown from ietf.utils.decorators import require_api_key from ietf.utils.hedgedoc import Note, NoteError -from ietf.utils.log import assertion +from ietf.utils.meetecho import MeetechoAPIError, SlidesManager +from ietf.utils.log import assertion, log from ietf.utils.mail import send_mail_message, send_mail_text from ietf.utils.mime import get_mime_type from ietf.utils.pipe import pipe @@ -102,7 +104,8 @@ from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm, InterimCancelForm, InterimSessionInlineFormSet, RequestMinutesForm, - UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm) + UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm, + UploadNarrativeMinutesForm) request_summary_exclude_group_types = ['team'] @@ -970,6 +973,7 @@ def cubehelix(i, total, hue=1.2, start_angle=0.5): 'rtg' : { 'dark' : (222, 219, 124) , 'light' : (247, 247, 233) }, 'sec' : { 'dark' : (0, 114, 178) , 'light' : (245, 252, 248) }, 'tsv' : { 'dark' : (117,201,119) , 'light' : (251, 252, 255) }, + 'wit' : { 'dark' : (117,201,119) , 'light' : (251, 252, 255) }, # intentionally the same as tsv } for i, p in enumerate(session_parents): if p.acronym in liz_preferred_colors: @@ -1692,45 +1696,58 @@ def api_get_agenda_data (request, num=None): "floors": list(map(agenda_extract_floorplan, floors)) }) -def api_get_session_materials (request, session_id=None): - session = get_object_or_404(Session,pk=session_id) + +def api_get_session_materials(request, session_id=None): + session = get_object_or_404(Session, pk=session_id) minutes = session.minutes() slides_actions = [] if can_manage_session_materials(request.user, session.group, session): - slides_actions.append({ - 'label': 'Upload slides', - 'url': reverse( - 'ietf.meeting.views.upload_session_slides', - kwargs={'num': session.meeting.number, 'session_id': session.pk}, - ), - }) + slides_actions.append( + { + "label": "Upload slides", + "url": reverse( + "ietf.meeting.views.upload_session_slides", + kwargs={"num": session.meeting.number, "session_id": session.pk}, + ), + } + ) elif not session.is_material_submission_cutoff(): - slides_actions.append({ - 'label': 'Propose slides', - 'url': reverse( - 'ietf.meeting.views.propose_session_slides', - kwargs={'num': session.meeting.number, 'session_id': session.pk}, - ), - }) + slides_actions.append( + { + "label": "Propose slides", + "url": reverse( + "ietf.meeting.views.propose_session_slides", + kwargs={"num": session.meeting.number, "session_id": session.pk}, + ), + } + ) else: pass # no action available if it's past cutoff - - agenda = session.agenda() + + agenda = session.agenda() agenda_url = agenda.get_href() if agenda is not None else None - return JsonResponse({ - "url": agenda_url, - "slides": { - "decks": list(map(agenda_extract_slide, session.slides())), - "actions": slides_actions, - }, - "minutes": { - "id": minutes.id, - "title": minutes.title, - "url": minutes.get_href(), - "ext": minutes.file_extension() - } if minutes is not None else None - }) + return JsonResponse( + { + "url": agenda_url, + "slides": { + "decks": [ + agenda_extract_slide(slide) | {"order": order} # add "order" field + for order, slide in enumerate(session.slides()) + ], + "actions": slides_actions, + }, + "minutes": { + "id": minutes.id, + "title": minutes.title, + "url": minutes.get_href(), + "ext": minutes.file_extension(), + } + if minutes is not None + else None, + } + ) + def agenda_extract_schedule (item): return { @@ -1753,14 +1770,14 @@ def agenda_extract_schedule (item): "filterKeywords": item.filter_keywords, "groupAcronym": item.session.group_at_the_time().acronym, "groupName": item.session.group_at_the_time().name, - "groupParent": { + "groupParent": ({ "acronym": item.session.group_parent_at_the_time().acronym - } if item.session.group_parent_at_the_time() else {}, + } if item.session.group_parent_at_the_time() else {}), "note": item.session.agenda_note, "remoteInstructions": item.session.remote_instructions, "flags": { "agenda": True if item.session.agenda() is not None else False, - "showAgenda": True if (item.session.agenda() is not None or item.session.remote_instructions or item.session.agenda_note) else False + "showAgenda": True if (item.session.agenda() is not None or item.session.remote_instructions) else False }, "agenda": { "url": item.session.agenda().get_href() @@ -1788,7 +1805,8 @@ def agenda_extract_schedule (item): # } } -def agenda_extract_floorplan (item): + +def agenda_extract_floorplan(item): try: item.image.width except FileNotFoundError: @@ -1801,10 +1819,11 @@ def agenda_extract_floorplan (item): "short": item.short, "width": item.image.width, "height": item.image.height, - "rooms": list(map(agenda_extract_room, item.room_set.all())) + "rooms": list(map(agenda_extract_room, item.room_set.all())), } -def agenda_extract_room (item): + +def agenda_extract_room(item): return { "id": item.id, "name": item.name, @@ -1816,7 +1835,8 @@ def agenda_extract_room (item): "bottom": item.bottom() } -def agenda_extract_recording (item): + +def agenda_extract_recording(item): return { "id": item.id, "name": item.name, @@ -1824,14 +1844,17 @@ def agenda_extract_recording (item): "url": item.external_url } -def agenda_extract_slide (item): + +def agenda_extract_slide(item): return { "id": item.id, "title": item.title, + "rev": item.rev, "url": item.get_versionless_href(), - "ext": item.file_extension() + "ext": item.file_extension(), } + def agenda_csv(schedule, filtered_assignments, utc=False): encoding = 'utf-8' response = HttpResponse(content_type=f"text/csv; charset={encoding}") @@ -2156,7 +2179,7 @@ def agenda_json(request, num=None): # time of the meeting assignments = preprocess_assignments_for_agenda(assignments, meeting, extra_prefetches=[ "session__materials__docevent_set", - "session__sessionpresentation_set", + "session__presentations", "timeslot__meeting" ]) for asgn in assignments: @@ -2426,12 +2449,23 @@ def session_details(request, num, acronym): session.cancelled = session.current_status in Session.CANCELED_STATUSES session.status = status_names.get(session.current_status, session.current_status) - session.filtered_artifacts = list(session.sessionpresentation_set.filter(document__type__slug__in=['agenda','minutes','bluesheets'])) - session.filtered_artifacts.sort(key=lambda d:['agenda','minutes','bluesheets'].index(d.document.type.slug)) - session.filtered_slides = session.sessionpresentation_set.filter(document__type__slug='slides').order_by('order') - session.filtered_drafts = session.sessionpresentation_set.filter(document__type__slug='draft') - session.filtered_chatlog_and_polls = session.sessionpresentation_set.filter(document__type__slug__in=('chatlog', 'polls')).order_by('document__type__slug') - # TODO FIXME Deleted materials shouldn't be in the sessionpresentation_set + if session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final: + artifact_types = ['agenda','minutes','narrativeminutes'] + if Attended.objects.filter(session=session).exists(): + session.type_counter.update(['bluesheets']) + ota = session.official_timeslotassignment() + sess_time = ota and ota.timeslot.time + session.bluesheet_title = 'Attendance IETF%s: %s : %s' % (session.meeting.number, + session.group.acronym, + sess_time.strftime("%a %H:%M")) + else: + artifact_types = ['agenda','minutes','narrativeminutes','bluesheets'] + session.filtered_artifacts = list(session.presentations.filter(document__type__slug__in=artifact_types)) + session.filtered_artifacts.sort(key=lambda d:artifact_types.index(d.document.type.slug)) + session.filtered_slides = session.presentations.filter(document__type__slug='slides').order_by('order') + session.filtered_drafts = session.presentations.filter(document__type__slug='draft') + session.filtered_chatlog_and_polls = session.presentations.filter(document__type__slug__in=('chatlog', 'polls')).order_by('document__type__slug') + # TODO FIXME Deleted materials shouldn't be in the presentations for qs in [session.filtered_artifacts,session.filtered_slides,session.filtered_drafts]: qs = [p for p in qs if p.document.get_state_slug(p.document.type_id)!='deleted'] session.type_counter.update([p.document.type.slug for p in qs]) @@ -2489,7 +2523,7 @@ def add_session_drafts(request, session_id, num): if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): raise Http404 - already_linked = [sp.document for sp in session.sessionpresentation_set.filter(document__type_id='draft')] + already_linked = [sp.document for sp in session.presentations.filter(document__type_id='draft')] session_number = None sessions = get_sessions(session.meeting.number,session.group.acronym) @@ -2500,7 +2534,7 @@ def add_session_drafts(request, session_id, num): form = SessionDraftsForm(request.POST,already_linked=already_linked) if form.is_valid(): for draft in form.cleaned_data['drafts']: - session.sessionpresentation_set.create(document=draft,rev=None) + session.presentations.create(document=draft,rev=None) c = DocEvent(type="added_comment", doc=draft, rev=draft.rev, by=request.user.person) c.desc = "Added to session: %s" % session c.save() @@ -2511,11 +2545,71 @@ def add_session_drafts(request, session_id, num): return render(request, "meeting/add_session_drafts.html", { 'session': session, 'session_number': session_number, - 'already_linked': session.sessionpresentation_set.filter(document__type_id='draft'), + 'already_linked': session.presentations.filter(document__type_id='draft'), 'form': form, }) +def session_attendance(request, session_id, num): + """Session attendance view + + GET - retrieve the current session attendance or redirect to the published bluesheet if finalized + + POST - self-attest attendance for logged-in user; falls through to GET for AnonymousUser or invalid request + """ + # num is redundant, but we're dragging it along as an artifact of where we are in the current URL structure + session = get_object_or_404(Session, pk=session_id) + if session.meeting.type_id != "ietf" or session.meeting.proceedings_final: + bluesheets = session.presentations.filter( + document__type_id="bluesheets" + ) + if bluesheets: + bluesheet = bluesheets[0].document + return redirect(bluesheet.get_href(session.meeting)) + else: + raise Http404("Bluesheets not found") + + cor_cut_off_date = session.meeting.get_submission_correction_date() + today_utc = date_today(datetime.timezone.utc) + was_there = False + can_add = False + if request.user.is_authenticated: + # use getattr() instead of request.user.person because it's a reverse OneToOne field + person = getattr(request.user, "person", None) + # Consider allowing self-declared attendance if we have a person and at least one Attended instance exists. + # The latter condition will be satisfied when Meetecho pushes their attendee records - assuming that at least + # one person will have accessed the meeting tool. This prevents people from self-declaring before they are + # marked as attending if they did log in to the meeting tool (except for a tiny window while records are + # being processed). + if person is not None and Attended.objects.filter(session=session).exists(): + was_there = Attended.objects.filter(session=session, person=person).exists() + can_add = ( + today_utc <= cor_cut_off_date + and MeetingRegistration.objects.filter( + meeting=session.meeting, person=person + ).exists() + and not was_there + ) + if can_add and request.method == "POST": + session.attended_set.get_or_create( + person=person, defaults={"origin": "self declared"} + ) + can_add = False + was_there = True + + data = bluesheet_data(session) + return render( + request, + "meeting/attendance.html", + { + "session": session, + "data": data, + "can_add": can_add, + "was_there": was_there, + }, + ) + + def upload_session_bluesheets(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) @@ -2553,7 +2647,7 @@ def upload_session_bluesheets(request, session_id, num): else: form = UploadBlueSheetForm() - bluesheet_sp = session.sessionpresentation_set.filter(document__type='bluesheets').first() + bluesheet_sp = session.presentations.filter(document__type='bluesheets').first() return render(request, "meeting/upload_session_bluesheets.html", {'session': session, @@ -2563,47 +2657,6 @@ def upload_session_bluesheets(request, session_id, num): }) -def save_bluesheet(request, session, file, encoding='utf-8'): - bluesheet_sp = session.sessionpresentation_set.filter(document__type='bluesheets').first() - _, ext = os.path.splitext(file.name) - - if bluesheet_sp: - doc = bluesheet_sp.document - doc.rev = '%02d' % (int(doc.rev)+1) - bluesheet_sp.rev = doc.rev - bluesheet_sp.save() - else: - ota = session.official_timeslotassignment() - sess_time = ota and ota.timeslot.time - - if session.meeting.type_id=='ietf': - name = 'bluesheets-%s-%s-%s' % (session.meeting.number, - session.group.acronym, - sess_time.strftime("%Y%m%d%H%M")) - title = 'Bluesheets IETF%s: %s : %s' % (session.meeting.number, - session.group.acronym, - sess_time.strftime("%a %H:%M")) - else: - name = 'bluesheets-%s-%s' % (session.meeting.number, sess_time.strftime("%Y%m%d%H%M")) - title = 'Bluesheets %s: %s' % (session.meeting.number, sess_time.strftime("%a %H:%M")) - doc = Document.objects.create( - name = name, - type_id = 'bluesheets', - title = title, - group = session.group, - rev = '00', - ) - doc.states.add(State.objects.get(type_id='bluesheets',slug='active')) - session.sessionpresentation_set.create(document=doc,rev='00') - filename = '%s-%s%s'% ( doc.name, doc.rev, ext) - doc.uploaded_filename = filename - e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev) - save_error = handle_upload_file(file, filename, session.meeting, 'bluesheets', request=request, encoding=encoding) - if not save_error: - doc.save_with_history([e]) - return save_error - - def upload_session_minutes(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) @@ -2619,7 +2672,7 @@ def upload_session_minutes(request, session_id, num): if len(sessions) > 1: session_number = 1 + sessions.index(session) - minutes_sp = session.sessionpresentation_set.filter(document__type='minutes').first() + minutes_sp = session.presentations.filter(document__type='minutes').first() if request.method == 'POST': form = UploadMinutesForm(show_apply_to_all_checkbox,request.POST,request.FILES) @@ -2661,6 +2714,95 @@ def upload_session_minutes(request, session_id, num): 'form': form, }) +@role_required("Secretariat") +def upload_session_narrativeminutes(request, session_id, num): + # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure + session = get_object_or_404(Session,pk=session_id) + if session.group.acronym != "iesg": + raise Http404() + + session_number = None + sessions = get_sessions(session.meeting.number,session.group.acronym) + show_apply_to_all_checkbox = len(sessions) > 1 if session.type_id == 'regular' else False + if len(sessions) > 1: + session_number = 1 + sessions.index(session) + + narrativeminutes_sp = session.presentations.filter(document__type='narrativeminutes').first() + + if request.method == 'POST': + form = UploadNarrativeMinutesForm(show_apply_to_all_checkbox,request.POST,request.FILES) + if form.is_valid(): + file = request.FILES['file'] + _, ext = os.path.splitext(file.name) + apply_to_all = session.type_id == 'regular' + if show_apply_to_all_checkbox: + apply_to_all = form.cleaned_data['apply_to_all'] + + # Set up the new revision + try: + save_session_minutes_revision( + session=session, + apply_to_all=apply_to_all, + file=file, + ext=ext, + encoding=form.file_encoding[file.name], + request=request, + narrative=True + ) + except SessionNotScheduledError: + return HttpResponseGone( + "Cannot receive uploads for an unscheduled session. Please check the session ID.", + content_type="text/plain", + ) + except SaveMaterialsError as err: + form.add_error(None, str(err)) + else: + # no exception -- success! + messages.success(request, f'Successfully uploaded narrative minutes as revision {session.narrative_minutes().rev}.') + return redirect('ietf.meeting.views.session_details', num=num, acronym=session.group.acronym) + else: + form = UploadMinutesForm(show_apply_to_all_checkbox) + + return render(request, "meeting/upload_session_narrativeminutes.html", + {'session': session, + 'session_number': session_number, + 'minutes_sp' : narrativeminutes_sp, + 'form': form, + }) + +class UploadOrEnterAgendaForm(UploadAgendaForm): + ACTIONS = [ + ("upload", "Upload agenda"), + ("enter", "Enter agenda"), + ] + submission_method = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect) + + content = forms.CharField(widget=forms.Textarea, required=False, strip=False, label="Agenda text") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["file"].required=False + self.order_fields(["submission_method", "file", "content"]) + + def clean_content(self): + return self.cleaned_data["content"].replace("\r", "") + + def clean_file(self): + submission_method = self.cleaned_data.get("submission_method") + if submission_method == "upload": + return super().clean_file() + return None + + def clean(self): + def require_field(f): + if not self.cleaned_data.get(f): + self.add_error(f, ValidationError("You must fill in this field.")) + + submission_method = self.cleaned_data.get("submission_method") + if submission_method == "upload": + require_field("file") + elif submission_method == "enter": + require_field("content") def upload_session_agenda(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure @@ -2677,13 +2819,26 @@ def upload_session_agenda(request, session_id, num): if len(sessions) > 1: session_number = 1 + sessions.index(session) - agenda_sp = session.sessionpresentation_set.filter(document__type='agenda').first() + agenda_sp = session.presentations.filter(document__type='agenda').first() if request.method == 'POST': - form = UploadAgendaForm(show_apply_to_all_checkbox,request.POST,request.FILES) + form = UploadOrEnterAgendaForm(show_apply_to_all_checkbox,request.POST,request.FILES) if form.is_valid(): - file = request.FILES['file'] - _, ext = os.path.splitext(file.name) + submission_method = form.cleaned_data['submission_method'] + if submission_method == "upload": + file = request.FILES['file'] + _, ext = os.path.splitext(file.name) + else: + if agenda_sp: + doc = agenda_sp.document + _, ext = os.path.splitext(doc.uploaded_filename) + else: + ext = ".md" + fd, name = tempfile.mkstemp(suffix=ext, text=True) + os.close(fd) + with open(name, "w") as file: + file.write(form.cleaned_data['content']) + file = open(name, "rb") apply_to_all = session.type.slug == 'regular' if show_apply_to_all_checkbox: apply_to_all = form.cleaned_data['apply_to_all'] @@ -2723,22 +2878,26 @@ def upload_session_agenda(request, session_id, num): rev = '00', ) doc.states.add(State.objects.get(type_id='agenda',slug='active')) - if session.sessionpresentation_set.filter(document=doc).exists(): - sp = session.sessionpresentation_set.get(document=doc) + if session.presentations.filter(document=doc).exists(): + sp = session.presentations.get(document=doc) sp.rev = doc.rev sp.save() else: - session.sessionpresentation_set.create(document=doc,rev=doc.rev) + session.presentations.create(document=doc,rev=doc.rev) if apply_to_all: for other_session in sessions: if other_session != session: - other_session.sessionpresentation_set.filter(document__type='agenda').delete() - other_session.sessionpresentation_set.create(document=doc,rev=doc.rev) + other_session.presentations.filter(document__type='agenda').delete() + other_session.presentations.create(document=doc,rev=doc.rev) filename = '%s-%s%s'% ( doc.name, doc.rev, ext) doc.uploaded_filename = filename e = NewRevisionDocEvent.objects.create(doc=doc,by=request.user.person,type='new_revision',desc='New revision available: %s'%doc.rev,rev=doc.rev) # The way this function builds the filename it will never trigger the file delete in handle_file_upload. - save_error = handle_upload_file(file, filename, session.meeting, 'agenda', request=request, encoding=form.file_encoding[file.name]) + try: + encoding=form.file_encoding[file.name] + except AttributeError: + encoding=None + save_error = handle_upload_file(file, filename, session.meeting, 'agenda', request=request, encoding=encoding) if save_error: form.add_error(None, save_error) else: @@ -2746,7 +2905,11 @@ def upload_session_agenda(request, session_id, num): messages.success(request, f'Successfully uploaded agenda as revision {doc.rev}.') return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym) else: - form = UploadAgendaForm(show_apply_to_all_checkbox, initial={'apply_to_all':session.type_id=='regular'}) + initial={'apply_to_all':session.type_id=='regular', 'submission_method':'upload'} + if agenda_sp: + doc = agenda_sp.document + initial['content'] = doc.text() + form = UploadOrEnterAgendaForm(show_apply_to_all_checkbox, initial=initial) return render(request, "meeting/upload_session_agenda.html", {'session': session, @@ -2757,103 +2920,175 @@ def upload_session_agenda(request, session_id, num): def upload_session_slides(request, session_id, num, name=None): + """Upload new or replacement slides for a session + + If name is None or "", expects a new set of slides. Otherwise, replaces the named slides with a new rev. + """ # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) session_number = None - sessions = get_sessions(session.meeting.number,session.group.acronym) - show_apply_to_all_checkbox = len(sessions) > 1 if session.type_id == 'regular' else False + sessions = get_sessions(session.meeting.number, session.group.acronym) + show_apply_to_all_checkbox = ( + len(sessions) > 1 if session.type_id == "regular" else False + ) if len(sessions) > 1: - session_number = 1 + sessions.index(session) + session_number = 1 + sessions.index(session) - slides = None - slides_sp = None + doc = None if name: - slides = Document.objects.filter(name=name).first() - if not (slides and slides.type_id=='slides'): - raise Http404 - slides_sp = session.sessionpresentation_set.filter(document=slides).first() - - if request.method == 'POST': - form = UploadSlidesForm(session, show_apply_to_all_checkbox,request.POST,request.FILES) + doc = get_object_or_404( + session.presentations, document__name=name, document__type_id="slides" + ).document + + if request.method == "POST": + form = UploadSlidesForm( + session, show_apply_to_all_checkbox, request.POST, request.FILES + ) if form.is_valid(): - file = request.FILES['file'] + file = request.FILES["file"] _, ext = os.path.splitext(file.name) - apply_to_all = session.type_id == 'regular' + apply_to_all = session.type_id == "regular" if show_apply_to_all_checkbox: - apply_to_all = form.cleaned_data['apply_to_all'] - if slides_sp: - doc = slides_sp.document - doc.rev = '%02d' % (int(doc.rev)+1) - doc.title = form.cleaned_data['title'] - slides_sp.rev = doc.rev - slides_sp.save() + apply_to_all = form.cleaned_data["apply_to_all"] + + # Handle creation / update of the Document (but do not save yet) + if doc is not None: + # This is a revision - bump the version and update the title. + doc.rev = "%02d" % (int(doc.rev) + 1) + doc.title = form.cleaned_data["title"] else: - title = form.cleaned_data['title'] - if session.meeting.type_id=='ietf': - name = 'slides-%s-%s' % (session.meeting.number, - session.group.acronym) + # This is a new slide deck - create a new doc unless one exists with that name + title = form.cleaned_data["title"] + if session.meeting.type_id == "ietf": + name = "slides-%s-%s" % ( + session.meeting.number, + session.group.acronym, + ) if not apply_to_all: - name += '-%s' % (session.docname_token(),) + name += "-%s" % (session.docname_token(),) else: - name = 'slides-%s-%s' % (session.meeting.number, session.docname_token()) - name = name + '-' + slugify(title).replace('_', '-')[:128] + name = "slides-%s-%s" % ( + session.meeting.number, + session.docname_token(), + ) + name = name + "-" + slugify(title).replace("_", "-")[:128] if Document.objects.filter(name=name).exists(): - doc = Document.objects.get(name=name) - doc.rev = '%02d' % (int(doc.rev)+1) - doc.title = form.cleaned_data['title'] + doc = Document.objects.get(name=name) + doc.rev = "%02d" % (int(doc.rev) + 1) + doc.title = form.cleaned_data["title"] else: doc = Document.objects.create( - name = name, - type_id = 'slides', - title = title, - group = session.group, - rev = '00', - ) - doc.states.add(State.objects.get(type_id='slides',slug='active')) - doc.states.add(State.objects.get(type_id='reuse_policy',slug='single')) - if session.sessionpresentation_set.filter(document=doc).exists(): - sp = session.sessionpresentation_set.get(document=doc) - sp.rev = doc.rev - sp.save() - else: - max_order = session.sessionpresentation_set.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - session.sessionpresentation_set.create(document=doc,rev=doc.rev,order=max_order+1) - if apply_to_all: - for other_session in sessions: - if other_session != session and not other_session.sessionpresentation_set.filter(document=doc).exists(): - max_order = other_session.sessionpresentation_set.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - other_session.sessionpresentation_set.create(document=doc,rev=doc.rev,order=max_order+1) - filename = '%s-%s%s'% ( doc.name, doc.rev, ext) + name=name, + type_id="slides", + title=title, + group=session.group, + rev="00", + ) + doc.states.add(State.objects.get(type_id="slides", slug="active")) + doc.states.add(State.objects.get(type_id="reuse_policy", slug="single")) + + # Now handle creation / update of the SessionPresentation(s) + sessions_to_apply = sessions if apply_to_all else [session] + added_presentations = [] + revised_presentations = [] + for sess in sessions_to_apply: + sp = sess.presentations.filter(document=doc).first() + if sp is not None: + sp.rev = doc.rev + sp.save() + revised_presentations.append(sp) + else: + max_order = ( + sess.presentations.filter(document__type="slides").aggregate( + Max("order") + )["order__max"] + or 0 + ) + sp = sess.presentations.create( + document=doc, rev=doc.rev, order=max_order + 1 + ) + added_presentations.append(sp) + + # Now handle the uploaded file + filename = "%s-%s%s" % (doc.name, doc.rev, ext) doc.uploaded_filename = filename - e = NewRevisionDocEvent.objects.create(doc=doc,by=request.user.person,type='new_revision',desc='New revision available: %s'%doc.rev,rev=doc.rev) + e = NewRevisionDocEvent.objects.create( + doc=doc, + by=request.user.person, + type="new_revision", + desc="New revision available: %s" % doc.rev, + rev=doc.rev, + ) # The way this function builds the filename it will never trigger the file delete in handle_file_upload. - save_error = handle_upload_file(file, filename, session.meeting, 'slides', request=request, encoding=form.file_encoding[file.name]) + save_error = handle_upload_file( + file, + filename, + session.meeting, + "slides", + request=request, + encoding=form.file_encoding[file.name], + ) if save_error: form.add_error(None, save_error) else: doc.save_with_history([e]) post_process(doc) + + # Send MeetEcho updates even if we had a problem saving - that will keep it in sync with the + # SessionPresentation, which was already saved regardless of problems saving the file. + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + for sp in added_presentations: + try: + sm.add(session=sp.session, slides=doc, order=sp.order) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.add(): {err}") + for sp in revised_presentations: + try: + sm.revise(session=sp.session, slides=doc) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.revise(): {err}") + + if not save_error: messages.success( request, - f'Successfully uploaded slides as revision {doc.rev} of {doc.name}.') - return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym) - else: + f"Successfully uploaded slides as revision {doc.rev} of {doc.name}.", + ) + return redirect( + "ietf.meeting.views.session_details", + num=num, + acronym=session.group.acronym, + ) + else: initial = {} - if slides: - initial = {'title':slides.title} + if doc is not None: + initial = {"title": doc.title} form = UploadSlidesForm(session, show_apply_to_all_checkbox, initial=initial) - return render(request, "meeting/upload_session_slides.html", - {'session': session, - 'session_number': session_number, - 'slides_sp' : slides_sp, - 'form': form, - }) + return render( + request, + "meeting/upload_session_slides.html", + { + "session": session, + "session_number": session_number, + "slides_sp": session.presentations.filter(document=doc).first() if doc else None, + "form": form, + }, + ) + + @login_required def propose_session_slides(request, session_id, num): session = get_object_or_404(Session,pk=session_id) @@ -2919,135 +3154,284 @@ def propose_session_slides(request, session_id, num): 'form': form, }) + def remove_sessionpresentation(request, session_id, num, name): - sp = get_object_or_404(SessionPresentation,session_id=session_id,document__name=name) + sp = get_object_or_404( + SessionPresentation, session_id=session_id, document__name=name + ) session = sp.session if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to manage materials for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") - if request.method == 'POST': - session.sessionpresentation_set.filter(pk=sp.pk).delete() - c = DocEvent(type="added_comment", doc=sp.document, rev=sp.document.rev, by=request.user.person) + permission_denied( + request, "You don't have permission to manage materials for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) + if request.method == "POST": + session.presentations.filter(pk=sp.pk).delete() + c = DocEvent( + type="added_comment", + doc=sp.document, + rev=sp.document.rev, + by=request.user.person, + ) c.desc = "Removed from session: %s" % (session) c.save() - messages.success(request, f'Successfully removed {name}.') - return redirect('ietf.meeting.views.session_details', num=session.meeting.number, acronym=session.group.acronym) + messages.success(request, f"Successfully removed {name}.") + if sp.document.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(session=session, slides=sp.document) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.delete(): {err}") + + return redirect( + "ietf.meeting.views.session_details", + num=session.meeting.number, + acronym=session.group.acronym, + ) + + return render(request, "meeting/remove_sessionpresentation.html", {"sp": sp}) - return render(request,'meeting/remove_sessionpresentation.html', {'sp': sp }) def ajax_add_slides_to_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - order_str = request.POST.get('order', None) + order_str = request.POST.get("order", None) try: order = int(order_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied order is not valid' }),content_type='application/json') - if order < 1 or order > session.sessionpresentation_set.filter(document__type_id='slides').count() + 1 : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied order is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied order is not valid"}), + content_type="application/json", + ) + if ( + order < 1 + or order > session.presentations.filter(document__type_id="slides").count() + 1 + ): + return HttpResponse( + json.dumps({"success": False, "error": "Supplied order is not valid"}), + content_type="application/json", + ) - name = request.POST.get('name', None) + name = request.POST.get("name", None) doc = Document.objects.filter(name=name).first() if not doc: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied name is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied name is not valid"}), + content_type="application/json", + ) - if not session.sessionpresentation_set.filter(document=doc).exists(): + if not session.presentations.filter(document=doc).exists(): condition_slide_order(session) - session.sessionpresentation_set.filter(document__type_id='slides', order__gte=order).update(order=F('order')+1) - session.sessionpresentation_set.create(document=doc,rev=doc.rev,order=order) - DocEvent.objects.create(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person, desc="Added to session: %s" % session) + session.presentations.filter( + document__type_id="slides", order__gte=order + ).update(order=F("order") + 1) + session.presentations.create(document=doc, rev=doc.rev, order=order) + DocEvent.objects.create( + type="added_comment", + doc=doc, + rev=doc.rev, + by=request.user.person, + desc="Added to session: %s" % session, + ) - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + # Notify Meetecho of new slides if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.add(session=session, slides=doc, order=order) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.add(): {err}") + + return HttpResponse(json.dumps({"success": True}), content_type="application/json") def ajax_remove_slides_from_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - oldIndex_str = request.POST.get('oldIndex', None) + oldIndex_str = request.POST.get("oldIndex", None) try: oldIndex = int(oldIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if oldIndex < 1 or oldIndex > session.sessionpresentation_set.filter(document__type_id='slides').count() : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if ( + oldIndex < 1 + or oldIndex > session.presentations.filter(document__type_id="slides").count() + ): + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) - name = request.POST.get('name', None) + name = request.POST.get("name", None) doc = Document.objects.filter(name=name).first() if not doc: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied name is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied name is not valid"}), + content_type="application/json", + ) condition_slide_order(session) - affected_presentations = session.sessionpresentation_set.filter(document=doc).first() + affected_presentations = session.presentations.filter(document=doc).first() if affected_presentations: if affected_presentations.order == oldIndex: affected_presentations.delete() - session.sessionpresentation_set.filter(document__type_id='slides', order__gt=oldIndex).update(order=F('order')-1) - DocEvent.objects.create(type="added_comment", doc=doc, rev=doc.rev, by=request.user.person, desc="Removed from session: %s" % session) - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + session.presentations.filter( + document__type_id="slides", order__gt=oldIndex + ).update(order=F("order") - 1) + DocEvent.objects.create( + type="added_comment", + doc=doc, + rev=doc.rev, + by=request.user.person, + desc="Removed from session: %s" % session, + ) + # Notify Meetecho of removed slides if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.delete(session=session, slides=doc) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.delete(): {err}") + # Report success + return HttpResponse( + json.dumps({"success": True}), content_type="application/json" + ) else: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Name does not match index' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Name does not match index"}), + content_type="application/json", + ) else: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'SessionPresentation not found' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "SessionPresentation not found"}), + content_type="application/json", + ) def ajax_reorder_slides_in_session(request, session_id, num): - session = get_object_or_404(Session,pk=session_id) + session = get_object_or_404(Session, pk=session_id) if not session.can_manage_materials(request.user): - permission_denied(request, "You don't have permission to upload slides for this session.") - if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"): - permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.") + permission_denied( + request, "You don't have permission to upload slides for this session." + ) + if session.is_material_submission_cutoff() and not has_role( + request.user, "Secretariat" + ): + permission_denied( + request, + "The materials cutoff for this session has passed. Contact the secretariat for further action.", + ) - if request.method != 'POST' or not request.POST: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'No data submitted or not POST' }),content_type='application/json') + if request.method != "POST" or not request.POST: + return HttpResponse( + json.dumps({"success": False, "error": "No data submitted or not POST"}), + content_type="application/json", + ) - num_slides_in_session = session.sessionpresentation_set.filter(document__type_id='slides').count() - oldIndex_str = request.POST.get('oldIndex', None) + session_slides = session.presentations.filter(document__type_id="slides") + num_slides_in_session = session_slides.count() + oldIndex_str = request.POST.get("oldIndex", None) try: oldIndex = int(oldIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if oldIndex < 1 or oldIndex > num_slides_in_session : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if oldIndex < 1 or oldIndex > num_slides_in_session: + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) - newIndex_str = request.POST.get('newIndex', None) + newIndex_str = request.POST.get("newIndex", None) try: newIndex = int(newIndex_str) except (ValueError, TypeError): - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') - if newIndex < 1 or newIndex > num_slides_in_session : - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) + if newIndex < 1 or newIndex > num_slides_in_session: + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) if newIndex == oldIndex: - return HttpResponse(json.dumps({ 'success' : False, 'error' : 'Supplied index is not valid' }),content_type='application/json') + return HttpResponse( + json.dumps({"success": False, "error": "Supplied index is not valid"}), + content_type="application/json", + ) condition_slide_order(session) - sp = session.sessionpresentation_set.get(order=oldIndex) + sp = session_slides.get(order=oldIndex) if oldIndex < newIndex: - session.sessionpresentation_set.filter(order__gt=oldIndex, order__lte=newIndex).update(order=F('order')-1) + session_slides.filter(order__gt=oldIndex, order__lte=newIndex).update( + order=F("order") - 1 + ) else: - session.sessionpresentation_set.filter(order__gte=newIndex, order__lt=oldIndex).update(order=F('order')+1) + session_slides.filter(order__gte=newIndex, order__lt=oldIndex).update( + order=F("order") + 1 + ) sp.order = newIndex sp.save() - return HttpResponse(json.dumps({'success':True}), content_type='application/json') + # Update slide order with Meetecho if the API is configured + if hasattr(settings, "MEETECHO_API_CONFIG"): + sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG) + try: + sm.send_update(session) + except MeetechoAPIError as err: + log(f"Error in SlidesManager.send_update(): {err}") + + return HttpResponse(json.dumps({"success": True}), content_type="application/json") @role_required('Secretariat') @@ -3693,7 +4077,7 @@ def organize_proceedings_sessions(sessions): if s.current_status != 'canceled': all_canceled = False by_name.setdefault(s.name, []) - if s.current_status != 'notmeet' or s.sessionpresentation_set.exists(): + if s.current_status != 'notmeet' or s.presentations.exists(): by_name[s.name].append(s) # for notmeet, only include sessions with materials for sess_name, ss in by_name.items(): session = ss[0] if ss else None @@ -3725,15 +4109,18 @@ def _format_materials(items): 'name': sess_name, 'session': session, 'canceled': all_canceled, - 'has_materials': s.sessionpresentation_set.exists(), + 'has_materials': s.presentations.exists(), 'agendas': _format_materials((s, s.agenda()) for s in ss), 'minutes': _format_materials((s, s.minutes()) for s in ss), 'bluesheets': _format_materials((s, s.bluesheets()) for s in ss), 'recordings': _format_materials((s, s.recordings()) for s in ss), + 'chatlogs': _format_materials((s, s.chatlogs()) for s in ss), 'slides': _format_materials((s, s.slides()) for s in ss), 'drafts': _format_materials((s, s.drafts()) for s in ss), 'last_update': session.last_update if hasattr(session, 'last_update') else None } + if session and session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final: + entry['attendances'] = _format_materials((s, s) for s in ss if Attended.objects.filter(session=s).exists()) if is_meeting: meeting_groups.append(entry) else: @@ -3828,12 +4215,11 @@ def proceedings(request, num=None): def finalize_proceedings(request, num=None): meeting = get_meeting(num) - if (meeting.number.isdigit() and int(meeting.number) <= 64) or not meeting.schedule or not meeting.schedule.assignments.exists() or meeting.proceedings_final: raise Http404 if request.method=='POST': - finalize(meeting) + finalize(request, meeting) return HttpResponseRedirect(reverse('ietf.meeting.views.proceedings',kwargs={'num':meeting.number})) return render(request, "meeting/finalize.html", {'meeting':meeting,}) @@ -4037,37 +4423,111 @@ def err(code, text): return HttpResponse("Done", status=200, content_type='text/plain') + @require_api_key @role_required('Recording Manager') # TODO : Rework how Meetecho interacts via APIs. There may be better paths to pursue than Personal API keys as they are currently defined. @csrf_exempt def api_add_session_attendees(request): + """Upload attendees for one or more sessions + + parameters: + apikey: the poster's personal API key + attended: json blob with + { + "session_id": session pk, + "attendees": [ + {"user_id": user-pk-1, "join_time": "2024-02-21T18:00:00Z"}, + {"user_id": user-pk-2, "join_time": "2024-02-21T18:00:01Z"}, + {"user_id": user-pk-3, "join_time": "2024-02-21T18:00:02Z"}, + ... + ] + } + """ + json_validator = jsonschema.Draft202012Validator( + schema={ + "type": "object", + "properties": { + "session_id": {"type": "integer"}, + "attendees": { + # Allow either old or new format until after IETF 119 + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, # old: array of user PKs + { + # new: array of user_id / join_time objects + "type": "array", + "items": { + "type": "object", + "properties": { + "user_id": {"type": "integer", }, + "join_time": {"type": "string", "format": "date-time"} + }, + "required": ["user_id", "join_time"], + }, + }, + ], + } + }, + "required": ["session_id", "attendees"], + }, + format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER, # format-checks disabled by default + ) def err(code, text): - return HttpResponse(text, status=code, content_type='text/plain') + return HttpResponse(text, status=code, content_type="text/plain") - if request.method != 'POST': + if request.method != "POST": return err(405, "Method not allowed") - attended_post = request.POST.get('attended') + attended_post = request.POST.get("attended") if not attended_post: return err(400, "Missing attended parameter") + + # Validate the request payload try: - attended = json.loads(attended_post) - except json.decoder.JSONDecodeError: - return err(400, "Malformed post") - if not ( 'session_id' in attended and type(attended['session_id']) is int ): - return err(400, "Malformed post") - session_id = attended['session_id'] - if not ( 'attendees' in attended and type(attended['attendees']) is list and all([type(el) is int for el in attended['attendees']]) ): + payload = json.loads(attended_post) + json_validator.validate(payload) + except (json.decoder.JSONDecodeError, jsonschema.exceptions.ValidationError): return err(400, "Malformed post") + + session_id = payload["session_id"] session = Session.objects.filter(pk=session_id).first() if not session: return err(400, "Invalid session") - users = User.objects.filter(pk__in=attended['attendees']) - if users.count() != len(attended['attendees']): - return err(400, "Invalid attendee") - for user in users: - session.attended_set.get_or_create(person=user.person) - return HttpResponse("Done", status=200, content_type='text/plain') + + attendees = payload["attendees"] + if len(attendees) > 0: + # Check whether we have old or new format + if type(attendees[0]) == int: + # it's the old format + users = User.objects.filter(pk__in=attendees) + if users.count() != len(payload["attendees"]): + return err(400, "Invalid attendee") + for user in users: + session.attended_set.get_or_create(person=user.person) + else: + # it's the new format + join_time_by_pk = { + att["user_id"]: datetime.datetime.fromisoformat( + att["join_time"].replace("Z", "+00:00") # Z not understood until py311 + ) + for att in attendees + } + persons = list(Person.objects.filter(user__pk__in=join_time_by_pk)) + if len(persons) != len(join_time_by_pk): + return err(400, "Invalid attendee") + to_create = [ + Attended(session=session, person=person, time=join_time_by_pk[person.user_id]) + for person in persons + ] + # Create in bulk, ignoring any that already exist + Attended.objects.bulk_create(to_create, ignore_conflicts=True) + + if session.meeting.type_id == "interim": + save_error = generate_bluesheet(request, session) + if save_error: + return err(400, save_error) + + return HttpResponse("Done", status=200, content_type="text/plain") + @require_api_key @role_required('Recording Manager') @@ -4092,7 +4552,7 @@ def err(code, text): session = Session.objects.filter(pk=session_id).first() if not session: return err(400, "Invalid session") - chatlog_sp = session.sessionpresentation_set.filter(document__type='chatlog').first() + chatlog_sp = session.presentations.filter(document__type='chatlog').first() if chatlog_sp: doc = chatlog_sp.document doc.rev = f"{(int(doc.rev)+1):02d}" @@ -4132,7 +4592,7 @@ def err(code, text): session = Session.objects.filter(pk=session_id).first() if not session: return err(400, "Invalid session") - polls_sp = session.sessionpresentation_set.filter(document__type='polls').first() + polls_sp = session.presentations.filter(document__type='polls').first() if polls_sp: doc = polls_sp.document doc.rev = f"{(int(doc.rev)+1):02d}" @@ -4549,18 +5009,18 @@ def approve_proposed_slides(request, slidesubmission_id, num): ) doc.states.add(State.objects.get(type_id='slides',slug='active')) doc.states.add(State.objects.get(type_id='reuse_policy',slug='single')) - if submission.session.sessionpresentation_set.filter(document=doc).exists(): - sp = submission.session.sessionpresentation_set.get(document=doc) + if submission.session.presentations.filter(document=doc).exists(): + sp = submission.session.presentations.get(document=doc) sp.rev = doc.rev sp.save() else: - max_order = submission.session.sessionpresentation_set.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - submission.session.sessionpresentation_set.create(document=doc,rev=doc.rev,order=max_order+1) + max_order = submission.session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 + submission.session.presentations.create(document=doc,rev=doc.rev,order=max_order+1) if apply_to_all: for other_session in sessions: - if other_session != submission.session and not other_session.sessionpresentation_set.filter(document=doc).exists(): - max_order = other_session.sessionpresentation_set.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 - other_session.sessionpresentation_set.create(document=doc,rev=doc.rev,order=max_order+1) + if other_session != submission.session and not other_session.presentations.filter(document=doc).exists(): + max_order = other_session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0 + other_session.presentations.create(document=doc,rev=doc.rev,order=max_order+1) sub_name, sub_ext = os.path.splitext(submission.filename) target_filename = '%s-%s%s' % (sub_name[:sub_name.rfind('-ss')],doc.rev,sub_ext) doc.uploaded_filename = target_filename diff --git a/ietf/message/factories.py b/ietf/message/factories.py new file mode 100644 index 0000000000..72781512e4 --- /dev/null +++ b/ietf/message/factories.py @@ -0,0 +1,27 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +import factory + +from ietf.person.models import Person +from .models import Message, SendQueue + + +class MessageFactory(factory.django.DjangoModelFactory): + class Meta: + model = Message + + by = factory.LazyFunction(lambda: Person.objects.get(name="(System)")) + subject = factory.Faker("sentence") + to = factory.Faker("email") + frm = factory.Faker("email") + cc = factory.Faker("email") + bcc = factory.Faker("email") + body = factory.Faker("paragraph") + content_type = "text/plain" + + +class SendQueueFactory(factory.django.DjangoModelFactory): + class Meta: + model = SendQueue + + by = factory.LazyFunction(lambda: Person.objects.get(name="(System)")) + message = factory.SubFactory(MessageFactory) diff --git a/ietf/message/tasks.py b/ietf/message/tasks.py new file mode 100644 index 0000000000..efd776b9d8 --- /dev/null +++ b/ietf/message/tasks.py @@ -0,0 +1,27 @@ +# Copyright The IETF Trust 2024 All Rights Reserved +# +# Celery task definitions +# +from celery import shared_task +from smtplib import SMTPException + +from ietf.message.utils import send_scheduled_message_from_send_queue +from ietf.message.models import SendQueue +from ietf.utils import log +from ietf.utils.mail import log_smtp_exception, send_error_email + + +@shared_task +def send_scheduled_mail_task(): + """Send scheduled email + + This is equivalent to `ietf/bin/send-scheduled-mail all`, which was the only form used in the cron job. + """ + needs_sending = SendQueue.objects.filter(sent_at=None).select_related("message") + for s in needs_sending: + try: + send_scheduled_message_from_send_queue(s) + log.log('Sent scheduled message %s "%s"' % (s.id, s.message.subject)) + except SMTPException as e: + log_smtp_exception(e) + send_error_email(e) diff --git a/ietf/message/tests.py b/ietf/message/tests.py index a027df4473..7fbd29167c 100644 --- a/ietf/message/tests.py +++ b/ietf/message/tests.py @@ -1,8 +1,9 @@ # Copyright The IETF Trust 2013-2020, All Rights Reserved # -*- coding: utf-8 -*- - - import datetime +import mock + +from smtplib import SMTPException from django.urls import reverse as urlreverse from django.utils import timezone @@ -10,7 +11,9 @@ import debug # pyflakes:ignore from ietf.group.factories import GroupFactory +from ietf.message.factories import SendQueueFactory from ietf.message.models import Message, SendQueue +from ietf.message.tasks import send_scheduled_mail_task from ietf.message.utils import send_scheduled_message_from_send_queue from ietf.person.models import Person from ietf.utils.mail import outbox, send_mail_text, send_mail_message, get_payload_text @@ -128,3 +131,22 @@ def test_send_mime_announcement(self): self.assertTrue("This is a test" in outbox[-1]["Subject"]) self.assertTrue("--NextPart" in outbox[-1].as_string()) self.assertTrue(SendQueue.objects.get(id=q.id).sent_at) + + +class TaskTests(TestCase): + @mock.patch("ietf.message.tasks.log_smtp_exception") + @mock.patch("ietf.message.tasks.send_scheduled_message_from_send_queue") + def test_send_scheduled_mail_task(self, mock_send_message, mock_log_smtp_exception): + not_yet_sent = SendQueueFactory() + SendQueueFactory(sent_at=timezone.now()) # already sent + send_scheduled_mail_task() + self.assertEqual(mock_send_message.call_count, 1) + self.assertEqual(mock_send_message.call_args[0], (not_yet_sent,)) + self.assertFalse(mock_log_smtp_exception.called) + + mock_send_message.reset_mock() + mock_send_message.side_effect = SMTPException + send_scheduled_mail_task() + self.assertEqual(mock_send_message.call_count, 1) + self.assertEqual(mock_send_message.call_args[0], (not_yet_sent,)) + self.assertTrue(mock_log_smtp_exception.called) diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index fc46970f9d..e54233eda0 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -2578,6 +2578,32 @@ "model": "doc.state", "pk": 177 }, + { + "fields": { + "desc": "", + "name": "Active", + "next_states": [], + "order": 0, + "slug": "active", + "type": "narrativeminutes", + "used": true + }, + "model": "doc.state", + "pk": 178 + }, + { + "fields": { + "desc": "", + "name": "Deleted", + "next_states": [], + "order": 1, + "slug": "deleted", + "type": "narrativeminutes", + "used": true + }, + "model": "doc.state", + "pk": 179 + }, { "fields": { "label": "State" @@ -2739,6 +2765,13 @@ "model": "doc.statetype", "pk": "minutes" }, + { + "fields": { + "label": "State" + }, + "model": "doc.statetype", + "pk": "narrativeminutes" + }, { "fields": { "label": "State" @@ -10763,6 +10796,17 @@ "model": "name.doctypename", "pk": "minutes" }, + { + "fields": { + "desc": "", + "name": "Narrative Minutes", + "order": 0, + "prefix": "narrative-minutes", + "used": true + }, + "model": "name.doctypename", + "pk": "narrativeminutes" + }, { "fields": { "desc": "", @@ -16734,7 +16778,7 @@ "fields": { "command": "xym", "switch": "--version", - "time": "2023-11-21T08:09:45.989Z", + "time": "2024-02-21T08:06:28.313Z", "used": true, "version": "xym 0.7.0" }, @@ -16745,7 +16789,7 @@ "fields": { "command": "pyang", "switch": "--version", - "time": "2023-11-21T08:09:46.322Z", + "time": "2024-02-21T08:06:28.663Z", "used": true, "version": "pyang 2.6.0" }, @@ -16756,7 +16800,7 @@ "fields": { "command": "yanglint", "switch": "--version", - "time": "2023-11-21T08:09:46.338Z", + "time": "2024-02-21T08:06:28.685Z", "used": true, "version": "yanglint SO 1.9.2" }, @@ -16767,9 +16811,9 @@ "fields": { "command": "xml2rfc", "switch": "--version", - "time": "2023-11-21T08:09:47.251Z", + "time": "2024-02-21T08:06:29.492Z", "used": true, - "version": "xml2rfc 3.18.2" + "version": "xml2rfc 3.19.4" }, "model": "utils.versioninfo", "pk": 4 diff --git a/ietf/name/migrations/0013_narrativeminutes.py b/ietf/name/migrations/0013_narrativeminutes.py new file mode 100644 index 0000000000..89aa75a371 --- /dev/null +++ b/ietf/name/migrations/0013_narrativeminutes.py @@ -0,0 +1,35 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.db import migrations, models + + +def forward(apps, schema_editor): + DocTypeName = apps.get_model("name", "DocTypeName") + DocTypeName.objects.create( + slug="narrativeminutes", + name="Narrative Minutes", + desc="", + used=True, + order=0, + prefix="narrative-minutes", + ) + + +def reverse(apps, schema_editor): + DocTypeName = apps.get_model("name", "DocTypeName") + DocTypeName.objects.filter(slug="narrativeminutes").delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("name", "0012_adjust_important_dates"), + ] + + operations = [ + migrations.AlterField( + model_name="doctypename", + name="prefix", + field=models.CharField(default="", max_length=32), + ), + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 9bedd6cc2b..8c2520a489 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -43,7 +43,7 @@ class DocRelationshipName(NameModel): class DocTypeName(NameModel): """Draft, Agenda, Minutes, Charter, Discuss, Guideline, Email, Review, Issue, Wiki, RFC""" - prefix = models.CharField(max_length=16, default="") + prefix = models.CharField(max_length=32, default="") class DocTagName(NameModel): """Waiting for Reference, IANA Coordination, Revised ID Needed, External Party, AD Followup, Point Raised - Writeup Needed, ...""" @@ -101,7 +101,7 @@ class DraftSubmissionStateName(NameModel): """Uploaded, Awaiting Submitter Authentication, Awaiting Approval from Previous Version Authors, Awaiting Initial Version Approval, Awaiting Manual Post, Cancelled, Posted""" - next_states = models.ManyToManyField('DraftSubmissionStateName', related_name="previous_states", blank=True) + next_states = models.ManyToManyField('name.DraftSubmissionStateName', related_name="previous_states", blank=True) class RoomResourceName(NameModel): "Room resources: Audio Stream, Meetecho, . . ." class IprDisclosureStateName(NameModel): diff --git a/ietf/nomcom/admin.py b/ietf/nomcom/admin.py index 4b18cc005c..1675d731a5 100644 --- a/ietf/nomcom/admin.py +++ b/ietf/nomcom/admin.py @@ -21,9 +21,9 @@ class NomComAdmin(admin.ModelAdmin): admin.site.register(NomCom, NomComAdmin) class NominationAdmin(admin.ModelAdmin): - list_display = ['id', 'position', 'candidate_name', 'candidate_email', 'candidate_phone', 'nominee', 'comments', 'nominator_email', 'user', 'time', 'share_nominator'] + list_display = ['id', 'position', 'candidate_name', 'candidate_email', 'candidate_phone', 'nominee', 'comments', 'nominator_email', 'person', 'time', 'share_nominator'] list_filter = ['time', 'share_nominator'] - raw_id_fields = ['nominee', 'comments', 'user'] + raw_id_fields = ['nominee', 'comments', 'person'] admin.site.register(Nomination, NominationAdmin) class NomineeAdmin(admin.ModelAdmin): @@ -51,9 +51,9 @@ def nominee(self, obj): return ", ".join(n.person.ascii for n in obj.nominees.all()) nominee.admin_order_field = 'nominees__person__ascii' # type: ignore # https://github.com/python/mypy/issues/2087 - list_display = ['id', 'nomcom', 'author', 'nominee', 'subject', 'type', 'user', 'time'] + list_display = ['id', 'nomcom', 'author', 'nominee', 'subject', 'type', 'person', 'time'] list_filter = ['nomcom', 'type', 'time', ] - raw_id_fields = ['positions', 'topics', 'user'] + raw_id_fields = ['positions', 'topics', 'person'] admin.site.register(Feedback, FeedbackAdmin) diff --git a/ietf/nomcom/factories.py b/ietf/nomcom/factories.py index 7999228c24..286e0229ab 100644 --- a/ietf/nomcom/factories.py +++ b/ietf/nomcom/factories.py @@ -9,7 +9,7 @@ from ietf.nomcom.models import NomCom, Position, Feedback, Nominee, NomineePosition, Nomination, Topic from ietf.group.factories import GroupFactory -from ietf.person.factories import PersonFactory, UserFactory +from ietf.person.factories import PersonFactory import debug # pyflakes:ignore @@ -199,7 +199,7 @@ class Meta: candidate_email = factory.LazyAttribute(lambda obj: obj.nominee.person.email()) candidate_phone = factory.Faker('phone_number') comments = factory.SubFactory(FeedbackFactory) - nominator_email = factory.LazyAttribute(lambda obj: obj.user.email) - user = factory.SubFactory(UserFactory) + nominator_email = factory.LazyAttribute(lambda obj: obj.person.user.email) + person = factory.SubFactory(PersonFactory) share_nominator = False diff --git a/ietf/nomcom/forms.py b/ietf/nomcom/forms.py index 919ed6e187..7db5009121 100644 --- a/ietf/nomcom/forms.py +++ b/ietf/nomcom/forms.py @@ -15,7 +15,7 @@ from ietf.nomcom.models import ( NomCom, Nomination, Nominee, NomineePosition, Position, Feedback, ReminderDates, Topic, Volunteer ) from ietf.nomcom.utils import (NOMINATION_RECEIPT_TEMPLATE, FEEDBACK_RECEIPT_TEMPLATE, - get_user_email, validate_private_key, validate_public_key, + get_person_email, validate_private_key, validate_public_key, make_nomineeposition, make_nomineeposition_for_newperson, create_feedback_email) from ietf.person.models import Email @@ -256,7 +256,7 @@ class NominateForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) - self.user = kwargs.pop('user', None) + self.person = kwargs.pop('person', None) self.public = kwargs.pop('public', None) super(NominateForm, self).__init__(*args, **kwargs) @@ -273,7 +273,7 @@ def __init__(self, *args, **kwargs): if not self.public: self.fields.pop('confirmation') - author = get_user_email(self.user) + author = get_person_email(self.person) if author: self.fields['nominator_email'].initial = author.address help_text = """(Nomcom Chair/Member: please fill this in. Use your own email address if the person making the @@ -303,7 +303,7 @@ def save(self, commit=True): author = None if self.public: - author = get_user_email(self.user) + author = get_person_email(self.person) else: if nominator_email: emails = Email.objects.filter(address=nominator_email) @@ -314,7 +314,7 @@ def save(self, commit=True): feedback = Feedback.objects.create(nomcom=self.nomcom, comments=self.nomcom.encrypt(qualifications), type=FeedbackTypeName.objects.get(slug='nomina'), - user=self.user) + person=self.person) feedback.positions.add(position) feedback.nominees.add(nominee) @@ -326,7 +326,7 @@ def save(self, commit=True): nomination.nominee = nominee nomination.comments = feedback nomination.share_nominator = share_nominator - nomination.user = self.user + nomination.person = self.person if commit: nomination.save() @@ -361,7 +361,7 @@ class NominateNewPersonForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) - self.user = kwargs.pop('user', None) + self.person = kwargs.pop('person', None) self.public = kwargs.pop('public', None) super(NominateNewPersonForm, self).__init__(*args, **kwargs) @@ -375,7 +375,7 @@ def __init__(self, *args, **kwargs): if not self.public: self.fields.pop('confirmation') - author = get_user_email(self.user) + author = get_person_email(self.person) if author: self.fields['nominator_email'].initial = author.address help_text = """(Nomcom Chair/Member: please fill this in. Use your own email address if the person making the @@ -416,7 +416,7 @@ def save(self, commit=True): author = None if self.public: - author = get_user_email(self.user) + author = get_person_email(self.person) else: if nominator_email: emails = Email.objects.filter(address=nominator_email) @@ -429,7 +429,7 @@ def save(self, commit=True): feedback = Feedback.objects.create(nomcom=self.nomcom, comments=self.nomcom.encrypt(qualifications), type=FeedbackTypeName.objects.get(slug='nomina'), - user=self.user) + person=self.person) feedback.positions.add(position) feedback.nominees.add(nominee) @@ -441,7 +441,7 @@ def save(self, commit=True): nomination.nominee = nominee nomination.comments = feedback nomination.share_nominator = share_nominator - nomination.user = self.user + nomination.person = self.person if commit: nomination.save() @@ -476,7 +476,7 @@ class FeedbackForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) - self.user = kwargs.pop('user', None) + self.person = kwargs.pop('person', None) self.public = kwargs.pop('public', None) self.position = kwargs.pop('position', None) self.nominee = kwargs.pop('nominee', None) @@ -484,7 +484,7 @@ def __init__(self, *args, **kwargs): super(FeedbackForm, self).__init__(*args, **kwargs) - author = get_user_email(self.user) + author = get_person_email(self.person) if self.public: self.fields.pop('nominator_email') @@ -514,7 +514,7 @@ def save(self, commit=True): author = None if self.public: - author = get_user_email(self.user) + author = get_person_email(self.person) else: nominator_email = self.cleaned_data['nominator_email'] if nominator_email: @@ -525,7 +525,7 @@ def save(self, commit=True): feedback.author = author.address feedback.nomcom = self.nomcom - feedback.user = self.user + feedback.person = self.person feedback.type = FeedbackTypeName.objects.get(slug='comment') feedback.comments = self.nomcom.encrypt(comment_text) feedback.save() @@ -578,7 +578,7 @@ class QuestionnaireForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.nomcom = kwargs.pop('nomcom', None) - self.user = kwargs.pop('user', None) + self.person = kwargs.pop('person', None) super(QuestionnaireForm, self).__init__(*args, **kwargs) self.fields['nominee'] = PositionNomineeField(nomcom=self.nomcom, required=True) @@ -588,13 +588,13 @@ def save(self, commit=True): comment_text = self.cleaned_data['comment_text'] (position, nominee) = self.cleaned_data['nominee'] - author = get_user_email(self.user) + author = get_person_email(self.person) if author: feedback.author = author feedback.nomcom = self.nomcom - feedback.user = self.user + feedback.person = self.person feedback.type = FeedbackTypeName.objects.get(slug='questio') feedback.comments = self.nomcom.encrypt(comment_text) feedback.save() @@ -659,9 +659,9 @@ class Meta: model = Feedback fields = ('type', ) - def set_nomcom(self, nomcom, user): + def set_nomcom(self, nomcom, person): self.nomcom = nomcom - self.user = user + self.person = person #self.fields['nominee'] = MultiplePositionNomineeField(nomcom=self.nomcom, #required=True, #widget=forms.SelectMultiple, @@ -670,7 +670,7 @@ def set_nomcom(self, nomcom, user): def save(self, commit=True): feedback = super(PendingFeedbackForm, self).save(commit=False) feedback.nomcom = self.nomcom - feedback.user = self.user + feedback.person = self.person feedback.save() return feedback @@ -700,9 +700,9 @@ class Meta: model = Feedback fields = ('type', ) - def set_nomcom(self, nomcom, user, instances=None): + def set_nomcom(self, nomcom, person, instances=None): self.nomcom = nomcom - self.user = user + self.person = person instances = instances or [] self.feedback_type = None for i in instances: @@ -782,7 +782,7 @@ def save(self, commit=True): nominee=nominee, comments=feedback, nominator_email=nominator_email, - user=self.user) + person=self.person) return feedback else: feedback.save() diff --git a/ietf/nomcom/migrations/0005_user_to_person.py b/ietf/nomcom/migrations/0005_user_to_person.py new file mode 100644 index 0000000000..66a6e99642 --- /dev/null +++ b/ietf/nomcom/migrations/0005_user_to_person.py @@ -0,0 +1,100 @@ +# Generated by Django 4.2.2 on 2023-06-14 19:47 + +from django.db import migrations +from django.db.models import OuterRef, Subquery +import django.db.models.deletion +import ietf.utils.models + + +def forward(apps, schema_editor): + Nomination = apps.get_model('nomcom', 'Nomination') + Person = apps.get_model("person", "Person") + Nomination.objects.exclude( + user__isnull=True + ).update( + person=Subquery( + Person.objects.filter(user_id=OuterRef("user_id")).values("pk")[:1] + ) + ) + + Feedback = apps.get_model('nomcom', 'Feedback') + Feedback.objects.exclude( + user__isnull=True + ).update( + person=Subquery( + Person.objects.filter(user_id=OuterRef("user_id")).values("pk")[:1] + ) + ) + +def reverse(apps, schema_editor): + Nomination = apps.get_model('nomcom', 'Nomination') + Person = apps.get_model("person", "Person") + Nomination.objects.exclude( + person__isnull=True + ).update( + user_id=Subquery( + Person.objects.filter(pk=OuterRef("person_id")).values("user_id")[:1] + ) + ) + + Feedback = apps.get_model('nomcom', 'Feedback') + Feedback.objects.exclude( + person__isnull=True + ).update( + user_id=Subquery( + Person.objects.filter(pk=OuterRef("person_id")).values("user_id")[:1] + ) + ) + +class Migration(migrations.Migration): + dependencies = [ + ("person", "0001_initial"), + ("nomcom", "0004_volunteer_origin_volunteer_time_volunteer_withdrawn"), + ] + + operations = [ + migrations.AddField( + model_name="feedback", + name="person", + field=ietf.utils.models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="person.person", + ), + ), + migrations.AddField( + model_name="nomination", + name="person", + field=ietf.utils.models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="person.person", + ), + ), + migrations.RunPython(forward, reverse), + migrations.RemoveField( + model_name="feedback", + name="user", + field=ietf.utils.models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="user.user", + ), + ), + migrations.RemoveField( + model_name="nomination", + name="user", + field=ietf.utils.models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="user.user", + ), + ), + ] diff --git a/ietf/nomcom/models.py b/ietf/nomcom/models.py index 51006a227e..2ed1124c5c 100644 --- a/ietf/nomcom/models.py +++ b/ietf/nomcom/models.py @@ -7,7 +7,6 @@ from django.db import models from django.db.models.signals import post_delete from django.conf import settings -from django.contrib.auth.models import User from django.template.loader import render_to_string from django.template.defaultfilters import linebreaks # type: ignore @@ -128,7 +127,7 @@ class Nomination(models.Model): nominee = ForeignKey('Nominee') comments = ForeignKey('Feedback') nominator_email = models.EmailField(verbose_name='Nominator Email', blank=True) - user = ForeignKey(User, editable=False, null=True, on_delete=models.SET_NULL) + person = ForeignKey(Person, editable=False, null=True, on_delete=models.SET_NULL) time = models.DateTimeField(auto_now_add=True) share_nominator = models.BooleanField(verbose_name='OK to share nominator\'s name with candidate', default=False, help_text='Check this box to allow the NomCom to let the ' @@ -148,7 +147,7 @@ class Nominee(models.Model): email = ForeignKey(Email) person = ForeignKey(Person, blank=True, null=True) - nominee_position = models.ManyToManyField('Position', through='NomineePosition') + nominee_position = models.ManyToManyField('nomcom.Position', through='nomcom.NomineePosition') duplicated = ForeignKey('Nominee', blank=True, null=True) nomcom = ForeignKey('NomCom') @@ -293,13 +292,13 @@ def get_description(self): class Feedback(models.Model): nomcom = ForeignKey('NomCom') author = models.EmailField(verbose_name='Author', blank=True) - positions = models.ManyToManyField('Position', blank=True) - nominees = models.ManyToManyField('Nominee', blank=True) - topics = models.ManyToManyField('Topic', blank=True) + positions = models.ManyToManyField('nomcom.Position', blank=True) + nominees = models.ManyToManyField('nomcom.Nominee', blank=True) + topics = models.ManyToManyField('nomcom.Topic', blank=True) subject = models.TextField(verbose_name='Subject', blank=True) comments = models.BinaryField(verbose_name='Comments') type = ForeignKey(FeedbackTypeName, blank=True, null=True) - user = ForeignKey(User, editable=False, blank=True, null=True, on_delete=models.SET_NULL) + person = ForeignKey(Person, editable=False, blank=True, null=True, on_delete=models.SET_NULL) time = models.DateTimeField(auto_now_add=True) objects = FeedbackManager() diff --git a/ietf/nomcom/resources.py b/ietf/nomcom/resources.py index c87e72eae6..109a136419 100644 --- a/ietf/nomcom/resources.py +++ b/ietf/nomcom/resources.py @@ -115,11 +115,11 @@ class Meta: api.nomcom.register(NomineePositionResource()) from ietf.name.resources import FeedbackTypeNameResource -from ietf.utils.resources import UserResource +from ietf.person.resources import PersonResource class FeedbackResource(ModelResource): nomcom = ToOneField(NomComResource, 'nomcom') type = ToOneField(FeedbackTypeNameResource, 'type', null=True) - user = ToOneField(UserResource, 'user', null=True) + person = ToOneField(PersonResource, 'person', null=True) positions = ToManyField(PositionResource, 'positions', null=True) nominees = ToManyField(NomineeResource, 'nominees', null=True) class Meta: @@ -136,18 +136,18 @@ class Meta: "time": ALL, "nomcom": ALL_WITH_RELATIONS, "type": ALL_WITH_RELATIONS, - "user": ALL_WITH_RELATIONS, + "person": ALL_WITH_RELATIONS, "positions": ALL_WITH_RELATIONS, "nominees": ALL_WITH_RELATIONS, } api.nomcom.register(FeedbackResource()) -from ietf.utils.resources import UserResource +from ietf.person.resources import PersonResource class NominationResource(ModelResource): position = ToOneField(PositionResource, 'position') nominee = ToOneField(NomineeResource, 'nominee') comments = ToOneField(FeedbackResource, 'comments') - user = ToOneField(UserResource, 'user', null=True) + person = ToOneField(PersonResource, 'person', null=True) class Meta: cache = SimpleCache() queryset = Nomination.objects.all() @@ -164,7 +164,7 @@ class Meta: "position": ALL_WITH_RELATIONS, "nominee": ALL_WITH_RELATIONS, "comments": ALL_WITH_RELATIONS, - "user": ALL_WITH_RELATIONS, + "person": ALL_WITH_RELATIONS, } api.nomcom.register(NominationResource()) diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index d3da0bddd6..cdbb860c43 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -689,20 +689,16 @@ def test_public_nominate_with_automatic_questionnaire(self): self.assertIn('nominee@', outbox[1]['To']) - def nominate_view(self, *args, **kwargs): - public = kwargs.pop('public', True) - searched_email = kwargs.pop('searched_email', None) - nominee_email = kwargs.pop('nominee_email', 'nominee@example.com') + def nominate_view(self, public=True, searched_email=None, + nominee_email='nominee@example.com', + nominator_email=COMMUNITY_USER+EMAIL_DOMAIN, + position='IAOC', confirmation=False): + if not searched_email: - searched_email = Email.objects.filter(address=nominee_email).first() - if not searched_email: - searched_email = EmailFactory(address=nominee_email, primary=True, origin='test') + searched_email = Email.objects.filter(address=nominee_email).first() or EmailFactory(address=nominee_email, primary=True, origin='test') if not searched_email.person: searched_email.person = PersonFactory() searched_email.save() - nominator_email = kwargs.pop('nominator_email', "%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) - position_name = kwargs.pop('position', 'IAOC') - confirmation = kwargs.pop('confirmation', False) if public: nominate_url = self.public_nominate_url @@ -726,7 +722,7 @@ def nominate_view(self, *args, **kwargs): q = PyQuery(response.content) self.assertEqual(len(q("#nominate-form")), 1) - position = Position.objects.get(name=position_name) + position = Position.objects.get(name=position) comment_text = 'Test nominate view. Comments with accents äöåÄÖÅ éáíóú âêîôû ü àèìòù.' candidate_phone = '123456' @@ -764,12 +760,9 @@ def nominate_view(self, *args, **kwargs): comments=feedback, nominator_email="%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) - def nominate_newperson_view(self, *args, **kwargs): - public = kwargs.pop('public', True) - nominee_email = kwargs.pop('nominee_email', 'nominee@example.com') - nominator_email = kwargs.pop('nominator_email', "%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) - position_name = kwargs.pop('position', 'IAOC') - confirmation = kwargs.pop('confirmation', False) + def nominate_newperson_view(self, public=True, nominee_email='nominee@example.com', + nominator_email=COMMUNITY_USER+EMAIL_DOMAIN, + position='IAOC', confirmation=False): if public: nominate_url = self.public_nominate_newperson_url @@ -793,7 +786,7 @@ def nominate_newperson_view(self, *args, **kwargs): q = PyQuery(response.content) self.assertEqual(len(q("#nominate-form")), 1) - position = Position.objects.get(name=position_name) + position = Position.objects.get(name=position) candidate_email = nominee_email candidate_name = 'nominee' comment_text = 'Test nominate view. Comments with accents äöåÄÖÅ éáíóú âêîôû ü àèìòù.' @@ -847,15 +840,13 @@ def test_add_questionnaire(self): self.access_chair_url(self.add_questionnaire_url) self.add_questionnaire() - def add_questionnaire(self, *args, **kwargs): - public = kwargs.pop('public', False) - nominee_email = kwargs.pop('nominee_email', 'nominee@example.com') - nominator_email = kwargs.pop('nominator_email', "%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) - position_name = kwargs.pop('position', 'IAOC') + def add_questionnaire(self, public=False, nominee_email='nominee@example.com', + nominator_email=COMMUNITY_USER+EMAIL_DOMAIN, + position='IAOC'): self.nominate_view(public=public, nominee_email=nominee_email, - position=position_name, + position=position, nominator_email=nominator_email) response = self.client.get(self.add_questionnaire_url) @@ -874,7 +865,7 @@ def add_questionnaire(self, *args, **kwargs): self.assertEqual(response.status_code, 200) self.assertContains(response, "questionnnaireform") - position = Position.objects.get(name=position_name) + position = Position.objects.get(name=position) nominee = Nominee.objects.get(email__address=nominee_email) comment_text = 'Test add questionnaire view. Comments with accents äöåÄÖÅ éáíóú âêîôû ü àèìòù.' @@ -924,16 +915,13 @@ def test_private_feedback(self): self.access_member_url(self.private_feedback_url) self.feedback_view(public=False) - def feedback_view(self, *args, **kwargs): - public = kwargs.pop('public', True) - nominee_email = kwargs.pop('nominee_email', 'nominee@example.com') - nominator_email = kwargs.pop('nominator_email', "%s%s" % (COMMUNITY_USER, EMAIL_DOMAIN)) - position_name = kwargs.pop('position', 'IAOC') - confirmation = kwargs.pop('confirmation', False) + def feedback_view(self, public=True, nominee_email='nominee@example.com', + nominator_email=COMMUNITY_USER+EMAIL_DOMAIN, + position='IAOC', confirmation=False): self.nominate_view(public=public, nominee_email=nominee_email, - position=position_name, + position=position, nominator_email=nominator_email) feedback_url = self.public_feedback_url @@ -956,7 +944,7 @@ def feedback_view(self, *args, **kwargs): self.assertEqual(response.status_code, 200) self.assertNotContains(response, "feedbackform") - position = Position.objects.get(name=position_name) + position = Position.objects.get(name=position) nominee = Nominee.objects.get(email__address=nominee_email) feedback_url += "?nominee=%d&position=%d" % (nominee.id, position.id) @@ -972,7 +960,7 @@ def feedback_view(self, *args, **kwargs): comments = 'Test feedback view. Comments with accents äöåÄÖÅ éáíóú âêîôû ü àèìòù.' test_data = {'comment_text': comments, - 'position_name': position.name, + 'position': position.name, 'nominee_name': nominee.email.person.name, 'nominee_email': nominee.email.address, 'confirmation': confirmation} @@ -1168,7 +1156,7 @@ def setUp(self): feedback = Feedback.objects.create(nomcom=self.nomcom, comments=self.nomcom.encrypt('some non-empty comments'), type=FeedbackTypeName.objects.get(slug='questio'), - user=User.objects.get(username=CHAIR_USER)) + person=User.objects.get(username=CHAIR_USER).person) feedback.positions.add(gen) feedback.nominees.add(n) @@ -2192,7 +2180,7 @@ def test_public_accepting_feedback(self): self.assertIn('not currently accepting feedback', unicontent(response)) test_data = {'comment_text': 'junk', - 'position_name': pos.name, + 'position': pos.name, 'nominee_name': pos.nominee_set.first().email.person.name, 'nominee_email': pos.nominee_set.first().email.address, 'confirmation': False, diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index 220f2e401d..23e89026d1 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -88,26 +88,21 @@ def get_year_by_nomcom(nomcom): return m.group(0) -def get_user_email(user): - # a user object already has an email field, but we don't want to - # overwrite anything that might be there, and we don't know that - # what's there is the right thing, so we cache the lookup results in a - # separate attribute - if not hasattr(user, "_email_cache"): - user._email_cache = None - if hasattr(user, "person"): - emails = user.person.email_set.filter(active=True).order_by('-time') - if emails: - user._email_cache = emails[0] - for email in emails: - if email.address.lower() == user.username.lower(): - user._email_cache = email +def get_person_email(person): + if not hasattr(person, "_email_cache"): + person._email_cache = None + emails = person.email_set.filter(active=True).order_by('-time') + if emails: + person._email_cache = emails[0] + for email in emails: + if email.address.lower() == person.user.username.lower(): + person._email_cache = email else: try: - user._email_cache = Email.objects.get(address=user.username) + person._email_cache = Email.objects.get(address=person.user.username) except ObjectDoesNotExist: pass - return user._email_cache + return person._email_cache def get_hash_nominee_position(date, nominee_position_id): return hmac.new(settings.NOMCOM_APP_SECRET, f"{date}{nominee_position_id}".encode('utf-8'), hashlib.sha256).hexdigest() diff --git a/ietf/nomcom/views.py b/ietf/nomcom/views.py index 7705be5697..d34126b1e7 100644 --- a/ietf/nomcom/views.py +++ b/ietf/nomcom/views.py @@ -454,23 +454,24 @@ def nominate(request, year, public, newperson): {'nomcom': nomcom, 'year': year}) + person = request.user.person if request.method == 'POST': if newperson: - form = NominateNewPersonForm(data=request.POST, nomcom=nomcom, user=request.user, public=public) + form = NominateNewPersonForm(data=request.POST, nomcom=nomcom, person=person, public=public) else: - form = NominateForm(data=request.POST, nomcom=nomcom, user=request.user, public=public) + form = NominateForm(data=request.POST, nomcom=nomcom, person=person, public=public) if form.is_valid(): form.save() messages.success(request, 'Your nomination has been registered. Thank you for the nomination.') if newperson: return redirect('ietf.nomcom.views.%s_nominate' % ('public' if public else 'private'), year=year) else: - form = NominateForm(nomcom=nomcom, user=request.user, public=public) + form = NominateForm(nomcom=nomcom, person=person, public=public) else: if newperson: - form = NominateNewPersonForm(nomcom=nomcom, user=request.user, public=public) + form = NominateNewPersonForm(nomcom=nomcom, person=person, public=public) else: - form = NominateForm(nomcom=nomcom, user=request.user, public=public) + form = NominateForm(nomcom=nomcom, person=person, public=public) return render(request, template, {'form': form, @@ -494,6 +495,7 @@ def feedback(request, year, public): nominee = None position = None topic = None + person = request.user.person if nomcom.group.state_id != 'conclude': selected_nominee = request.GET.get('nominee') selected_position = request.GET.get('position') @@ -505,7 +507,7 @@ def feedback(request, year, public): topic = get_object_or_404(Topic,id=selected_topic) if topic.audience_id == 'nomcom' and not nomcom.group.has_role(request.user, ['chair','advisor','liaison','member']): raise Http404() - if topic.audience_id == 'nominees' and not nomcom.nominee_set.filter(person=request.user.person).exists(): + if topic.audience_id == 'nominees' and not nomcom.nominee_set.filter(person=person).exists(): raise Http404() if public: @@ -517,12 +519,12 @@ def feedback(request, year, public): if not nomcom.group.has_role(request.user, ['chair','advisor','liaison','member']): topics = topics.exclude(audience_id='nomcom') - if not nomcom.nominee_set.filter(person=request.user.person).exists(): + if not nomcom.nominee_set.filter(person=person).exists(): topics = topics.exclude(audience_id='nominees') user_comments = Feedback.objects.filter(nomcom=nomcom, type='comment', - author__in=request.user.person.email_set.filter(active='True')) + author__in=person.email_set.filter(active='True')) counter = Counter(user_comments.values_list('positions','nominees')) counts = dict() for pos,nom in counter: @@ -572,11 +574,11 @@ def feedback(request, year, public): if request.method == 'POST': if nominee and position: form = FeedbackForm(data=request.POST, - nomcom=nomcom, user=request.user, + nomcom=nomcom, person=person, public=public, position=position, nominee=nominee) elif topic: form = FeedbackForm(data=request.POST, - nomcom=nomcom, user=request.user, + nomcom=nomcom, person=person, public=public, topic=topic) else: form = None @@ -595,10 +597,10 @@ def feedback(request, year, public): pass else: if nominee and position: - form = FeedbackForm(nomcom=nomcom, user=request.user, public=public, + form = FeedbackForm(nomcom=nomcom, person=person, public=public, position=position, nominee=nominee) elif topic: - form = FeedbackForm(nomcom=nomcom, user=request.user, public=public, + form = FeedbackForm(nomcom=nomcom, person=person, public=public, topic=topic) else: form = None @@ -661,6 +663,7 @@ def private_questionnaire(request, year): has_publickey = nomcom.public_key and True or False questionnaire_response = None template = 'nomcom/private_questionnaire.html' + person = request.user.person if not has_publickey: messages.warning(request, "This Nomcom is not yet accepting questionnaires.") @@ -680,14 +683,14 @@ def private_questionnaire(request, year): if request.method == 'POST': form = QuestionnaireForm(data=request.POST, - nomcom=nomcom, user=request.user) + nomcom=nomcom, person=person) if form.is_valid(): form.save() messages.success(request, 'The questionnaire response has been registered.') questionnaire_response = force_str(form.cleaned_data['comment_text']) - form = QuestionnaireForm(nomcom=nomcom, user=request.user) + form = QuestionnaireForm(nomcom=nomcom, person=person) else: - form = QuestionnaireForm(nomcom=nomcom, user=request.user) + form = QuestionnaireForm(nomcom=nomcom, person=person) return render(request, template, {'form': form, @@ -725,15 +728,13 @@ def process_nomination_status(request, year, nominee_position_id, state, date, h if form.cleaned_data['comments']: # This Feedback object is of type comment instead of nomina in order to not # make answering "who nominated themselves" harder. - who = request.user - if isinstance(who,AnonymousUser): - who = None + who = None if isinstance(request.user, AnonymousUser) else request.user.person f = Feedback.objects.create(nomcom = nomcom, author = nominee_position.nominee.email, subject = '%s nomination %s'%(nominee_position.nominee.name(),state), comments = nomcom.encrypt(form.cleaned_data['comments']), type_id = 'comment', - user = who, + person = who, ) f.positions.add(nominee_position.position) f.nominees.add(nominee_position.nominee) @@ -779,8 +780,9 @@ def nominee_staterank(nominee): sorted_nominees = sorted(nominees,key=lambda x:x.staterank) + reviewer = request.user.person for nominee in sorted_nominees: - last_seen = FeedbackLastSeen.objects.filter(reviewer=request.user.person,nominee=nominee).first() + last_seen = FeedbackLastSeen.objects.filter(reviewer=reviewer,nominee=nominee).first() nominee_feedback = [] for ft in nominee_feedback_types: qs = nominee.feedback_set.by_type(ft.slug) @@ -795,7 +797,7 @@ def nominee_staterank(nominee): nominees_feedback.append( {'nominee':nominee, 'feedback':nominee_feedback} ) independent_feedback = [ft.feedback_set.get_by_nomcom(nomcom).count() for ft in independent_feedback_types] for topic in nomcom.topic_set.all(): - last_seen = TopicFeedbackLastSeen.objects.filter(reviewer=request.user.person,topic=topic).first() + last_seen = TopicFeedbackLastSeen.objects.filter(reviewer=reviewer,topic=topic).first() topic_feedback = [] for ft in topic_feedback_types: qs = topic.feedback_set.by_type(ft.slug) @@ -842,6 +844,7 @@ def view_feedback_pending(request, year): except EmptyPage: feedback_page = paginator.page(paginator.num_pages) extra_step = False + person = request.user.person if request.method == 'POST' and request.POST.get('end'): extra_ids = request.POST.get('extra_ids', None) extra_step = True @@ -850,7 +853,7 @@ def view_feedback_pending(request, year): formset.absolute_max = 2000 formset.validate_max = False for form in formset.forms: - form.set_nomcom(nomcom, request.user) + form.set_nomcom(nomcom, person) if formset.is_valid(): formset.save() if extra_ids: @@ -862,7 +865,7 @@ def view_feedback_pending(request, year): extra.append(feedback) formset = FullFeedbackFormSet(queryset=Feedback.objects.filter(id__in=[i.id for i in extra])) for form in formset.forms: - form.set_nomcom(nomcom, request.user, extra) + form.set_nomcom(nomcom, person, extra) extra_ids = None else: messages.success(request, 'Feedback saved') @@ -870,7 +873,7 @@ def view_feedback_pending(request, year): elif request.method == 'POST': formset = FeedbackFormSet(request.POST) for form in formset.forms: - form.set_nomcom(nomcom, request.user) + form.set_nomcom(nomcom, person) if formset.is_valid(): extra = [] nominations = [] @@ -890,12 +893,12 @@ def view_feedback_pending(request, year): if nominations: formset = FullFeedbackFormSet(queryset=Feedback.objects.filter(id__in=[i.id for i in nominations])) for form in formset.forms: - form.set_nomcom(nomcom, request.user, nominations) + form.set_nomcom(nomcom, person, nominations) extra_ids = ','.join(['%s:%s' % (i.id, i.type.pk) for i in extra]) else: formset = FullFeedbackFormSet(queryset=Feedback.objects.filter(id__in=[i.id for i in extra])) for form in formset.forms: - form.set_nomcom(nomcom, request.user, extra) + form.set_nomcom(nomcom, person, extra) if moved: messages.success(request, '%s messages classified. You must enter more information for the following feedback.' % moved) else: @@ -904,7 +907,7 @@ def view_feedback_pending(request, year): else: formset = FeedbackFormSet(queryset=feedback_page.object_list) for form in formset.forms: - form.set_nomcom(nomcom, request.user) + form.set_nomcom(nomcom, person) return render(request, 'nomcom/view_feedback_pending.html', {'year': year, 'formset': formset, @@ -975,13 +978,14 @@ def view_feedback_topic(request, year, topic_id): topic = get_object_or_404(Topic, id=topic_id) nomcom = get_nomcom_by_year(year) feedback_types = FeedbackTypeName.objects.filter(slug__in=['comment',]) + reviewer = request.user.person - last_seen = TopicFeedbackLastSeen.objects.filter(reviewer=request.user.person,topic=topic).first() + last_seen = TopicFeedbackLastSeen.objects.filter(reviewer=reviewer,topic=topic).first() last_seen_time = (last_seen and last_seen.time) or datetime.datetime(year=1, month=1, day=1, tzinfo=datetime.timezone.utc) if last_seen: last_seen.save() else: - TopicFeedbackLastSeen.objects.create(reviewer=request.user.person,topic=topic) + TopicFeedbackLastSeen.objects.create(reviewer=reviewer,topic=topic) return render(request, 'nomcom/view_feedback_topic.html', {'year': year, @@ -997,7 +1001,7 @@ def view_feedback_nominee(request, year, nominee_id): nomcom = get_nomcom_by_year(year) nominee = get_object_or_404(Nominee, id=nominee_id) feedback_types = FeedbackTypeName.objects.filter(used=True, slug__in=settings.NOMINEE_FEEDBACK_TYPES) - + reviewer = request.user.person if request.method == 'POST': if not nomcom.group.has_role(request.user, ['chair','advisor']): return HttpResponseForbidden('Restricted to roles: Nomcom Chair, Nomcom Advisor') @@ -1036,12 +1040,12 @@ def view_feedback_nominee(request, year, nominee_id): 'is_chair_task': True, }) - last_seen = FeedbackLastSeen.objects.filter(reviewer=request.user.person,nominee=nominee).first() + last_seen = FeedbackLastSeen.objects.filter(reviewer=reviewer,nominee=nominee).first() last_seen_time = (last_seen and last_seen.time) or datetime.datetime(year=1, month=1, day=1, tzinfo=datetime.timezone.utc) if last_seen: last_seen.save() else: - FeedbackLastSeen.objects.create(reviewer=request.user.person,nominee=nominee) + FeedbackLastSeen.objects.create(reviewer=reviewer,nominee=nominee) return render(request, 'nomcom/view_feedback_nominee.html', {'year': year, @@ -1322,15 +1326,15 @@ def configuration_help(request, year): @role_required("Nomcom Chair", "Nomcom Advisor") def edit_members(request, year): nomcom = get_nomcom_by_year(year) - if nomcom.group.state_id=='conclude': permission_denied(request, 'This nomcom is closed.') + person = request.user.person if request.method=='POST': form = EditMembersForm(nomcom, data=request.POST) if form.is_valid(): - update_role_set(nomcom.group, 'member', form.cleaned_data['members'], request.user.person) - update_role_set(nomcom.group, 'liaison', form.cleaned_data['liaisons'], request.user.person) + update_role_set(nomcom.group, 'member', form.cleaned_data['members'], person) + update_role_set(nomcom.group, 'liaison', form.cleaned_data['liaisons'], person) return HttpResponseRedirect(reverse('ietf.nomcom.views.private_index',kwargs={'year':year})) else: form = EditMembersForm(nomcom) diff --git a/ietf/person/tests.py b/ietf/person/tests.py index bb75b438db..be3cfc0562 100644 --- a/ietf/person/tests.py +++ b/ietf/person/tests.py @@ -25,11 +25,11 @@ from ietf.nomcom.models import NomCom from ietf.nomcom.test_data import nomcom_test_data from ietf.nomcom.factories import NomComFactory, NomineeFactory, NominationFactory, FeedbackFactory, PositionFactory -from ietf.person.factories import EmailFactory, PersonFactory, UserFactory +from ietf.person.factories import EmailFactory, PersonFactory from ietf.person.models import Person, Alias from ietf.person.utils import (merge_persons, determine_merge_order, send_merge_notification, handle_users, get_extra_primary, dedupe_aliases, move_related_objects, merge_nominees, - handle_reviewer_settings, merge_users, get_dots) + handle_reviewer_settings, get_dots) from ietf.review.models import ReviewerSettings from ietf.utils.test_utils import TestCase, login_testing_unauthorized from ietf.utils.mail import outbox, empty_outbox @@ -165,6 +165,16 @@ def test_person_photo(self): img = Image.open(BytesIO(r.content)) self.assertEqual(img.width, 200) + def test_person_photo_duplicates(self): + person = PersonFactory(name="bazquux@example.com", user__username="bazquux@example.com", with_bio=True) + PersonFactory(name="bazquux@example.com", user__username="foobar@example.com", with_bio=True) + + url = urlreverse("ietf.person.views.photo", kwargs={ "email_or_name": person.plain_name()}) + r = self.client.get(url) + self.assertEqual(r.status_code, 300) + self.assertIn("bazquux@example.com", r.content.decode()) + self.assertIn("foobar@example.com", r.content.decode()) + def test_name_methods(self): person = PersonFactory(name="Dr. Jens F. Möller", ) @@ -381,13 +391,24 @@ def test_merge_persons(self): request.user = user source = PersonFactory() target = PersonFactory() + mars = RoleFactory(name_id='chair',group__acronym='mars').group source_id = source.pk source_email = source.email_set.first() source_alias = source.alias_set.first() source_user = source.user + communitylist = CommunityList.objects.create(person=source, group=mars) + nomcom = NomComFactory() + position = PositionFactory(nomcom=nomcom) + nominee = NomineeFactory(nomcom=nomcom, person=mars.get_chair().person) + feedback = FeedbackFactory(person=source, author=source.email().address, nomcom=nomcom) + feedback.nominees.add(nominee) + nomination = NominationFactory(nominee=nominee, person=source, position=position, comments=feedback) merge_persons(request, source, target, file=StringIO()) self.assertTrue(source_email in target.email_set.all()) self.assertTrue(source_alias in target.alias_set.all()) + self.assertIn(communitylist, target.communitylist_set.all()) + self.assertIn(feedback, target.feedback_set.all()) + self.assertIn(nomination, target.nomination_set.all()) self.assertFalse(Person.objects.filter(id=source_id)) self.assertFalse(source_user.is_active) @@ -407,24 +428,6 @@ def test_merge_persons_reviewer_settings(self): rs = target.reviewersettings_set.first() self.assertEqual(rs.min_interval, 7) - def test_merge_users(self): - person = PersonFactory() - source = person.user - target = UserFactory() - mars = RoleFactory(name_id='chair',group__acronym='mars').group - communitylist = CommunityList.objects.create(user=source, group=mars) - nomcom = NomComFactory() - position = PositionFactory(nomcom=nomcom) - nominee = NomineeFactory(nomcom=nomcom, person=mars.get_chair().person) - feedback = FeedbackFactory(user=source, author=person.email().address, nomcom=nomcom) - feedback.nominees.add(nominee) - nomination = NominationFactory(nominee=nominee, user=source, position=position, comments=feedback) - - merge_users(source, target) - self.assertIn(communitylist, target.communitylist_set.all()) - self.assertIn(feedback, target.feedback_set.all()) - self.assertIn(nomination, target.nomination_set.all()) - def test_dots(self): noroles = PersonFactory() self.assertEqual(get_dots(noroles),[]) diff --git a/ietf/person/utils.py b/ietf/person/utils.py index 942c2aaab2..eb2742ed30 100755 --- a/ietf/person/utils.py +++ b/ietf/person/utils.py @@ -12,10 +12,11 @@ from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q +from django.http import Http404 import debug # pyflakes:ignore -from ietf.person.models import Person +from ietf.person.models import Person, Alias, Email from ietf.utils.mail import send_mail def merge_persons(request, source, target, file=sys.stdout, verbose=False): @@ -31,6 +32,20 @@ def merge_persons(request, source, target, file=sys.stdout, verbose=False): email.save() changes.append('EMAIL ACTION: {} no longer marked as primary'.format(email.address)) + # handle community list + for communitylist in source.communitylist_set.all(): + source.communitylist_set.remove(communitylist) + target.communitylist_set.add(communitylist) + + # handle feedback + for feedback in source.feedback_set.all(): + feedback.person = target + feedback.save() + # handle nominations + for nomination in source.nomination_set.all(): + nomination.person = target + nomination.save() + changes.append(handle_users(source, target)) reviewer_changes = handle_reviewer_settings(source, target) if reviewer_changes: @@ -103,7 +118,6 @@ def handle_users(source,target,check_only=False): if source.user and target.user: message = "DATATRACKER LOGIN ACTION: retaining login: {}, removing login: {}".format(target.user,source.user) if not check_only: - merge_users(source.user, target.user) syslog.syslog('merge-person-records: deactivating user {}'.format(source.user.username)) user = source.user source.user = None @@ -126,21 +140,6 @@ def move_related_objects(source, target, file, verbose=False): kwargs = { field_name:target } queryset.update(**kwargs) -def merge_users(source, target): - '''Move related objects from source user to target user''' - # handle community list - for communitylist in source.communitylist_set.all(): - source.communitylist_set.remove(communitylist) - target.communitylist_set.add(communitylist) - # handle feedback - for feedback in source.feedback_set.all(): - feedback.user = target - feedback.save() - # handle nominations - for nomination in source.nomination_set.all(): - nomination.user = target - nomination.save() - def dedupe_aliases(person): '''Check person for duplicate aliases and purge''' seen = [] @@ -248,3 +247,17 @@ def get_dots(person): if roles.filter(group__acronym__startswith='nomcom', name_id__in=('chair','member')).exists(): dots.append('nomcom') return dots + +def lookup_persons(email_or_name): + aliases = Alias.objects.filter(name__iexact=email_or_name) + persons = set(a.person for a in aliases) + + if '@' in email_or_name: + emails = Email.objects.filter(address__iexact=email_or_name) + persons.update(e.person for e in emails) + + persons = [p for p in persons if p and p.id] + if not persons: + raise Http404 + persons.sort(key=lambda p: p.id) + return persons diff --git a/ietf/person/views.py b/ietf/person/views.py index 23a8dea3d2..6d9daf4a81 100644 --- a/ietf/person/views.py +++ b/ietf/person/views.py @@ -8,16 +8,16 @@ from django.contrib import messages from django.db.models import Q from django.http import HttpResponse, Http404 -from django.shortcuts import render, get_object_or_404, redirect +from django.shortcuts import render, redirect from django.utils import timezone import debug # pyflakes:ignore from ietf.ietfauth.utils import role_required -from ietf.person.models import Email, Person, Alias +from ietf.person.models import Email, Person from ietf.person.fields import select2_id_name_json from ietf.person.forms import MergeForm -from ietf.person.utils import handle_users, merge_persons +from ietf.person.utils import handle_users, merge_persons, lookup_persons def ajax_select2_search(request, model_name): @@ -69,30 +69,14 @@ def ajax_select2_search(request, model_name): def profile(request, email_or_name): - aliases = Alias.objects.filter(name__iexact=email_or_name) - persons = set(a.person for a in aliases) - - if '@' in email_or_name: - emails = Email.objects.filter(address__iexact=email_or_name) - persons.update(e.person for e in emails) - - persons = [p for p in persons if p and p.id] - if not persons: - raise Http404 - persons.sort(key=lambda p: p.id) + persons = lookup_persons(email_or_name) return render(request, 'person/profile.html', {'persons': persons, 'today': timezone.now()}) def photo(request, email_or_name): - if '@' in email_or_name: - persons = [ get_object_or_404(Email, address=email_or_name).person, ] - else: - aliases = Alias.objects.filter(name=email_or_name) - persons = list(set([ a.person for a in aliases ])) - if not persons: - raise Http404("No such person") + persons = lookup_persons(email_or_name) if len(persons) > 1: - return HttpResponse(r"\r\n".join([p.email() for p in persons]), status=300) + return HttpResponse(r"\r\n".join([p.user.username for p in persons]), status=300) person = persons[0] if not person.photo: raise Http404("No photo found") diff --git a/ietf/review/policies.py b/ietf/review/policies.py index 6738db95ff..91398a1b24 100644 --- a/ietf/review/policies.py +++ b/ietf/review/policies.py @@ -131,12 +131,15 @@ def _update_skip_next(self, rotation_pks, assignee_person): assignee_index = rotation_pks.index(assignee_person.pk) skipped = rotation_pks[0:assignee_index] skipped_settings = self.team.reviewersettings_set.filter(person__in=skipped) # list of PKs is valid here + changed = [] for ss in skipped_settings: - ss.skip_next = max(0, ss.skip_next - 1) # ensure we don't go negative - bulk_update_with_history(skipped_settings, + if ss.skip_next > 0: + ss.skip_next = max(0, ss.skip_next - 1) # ensure we don't go negative + ss._change_reason = "Skip count decremented" + changed.append(ss) + bulk_update_with_history(changed, ReviewerSettings, - ['skip_next'], - default_change_reason='skipped') + ['skip_next']) def _assignment_in_order(self, rotation_pks, assignee_person): """Is this an in-order assignment?""" @@ -262,12 +265,15 @@ def _filter_unavailable_reviewers(self, reviewers, review_req=None): def _clear_request_next_assignment(self, person): s = self._reviewer_settings_for(person) - s.request_assignment_next = False - s.save() + if s.request_assignment_next: + s.request_assignment_next = False + s._change_reason = "Clearing request next assignment" + s.save() def _add_skip(self, person): s = self._reviewer_settings_for(person) s.skip_next += 1 + s._change_reason = "Incrementing skip count" s.save() def _reviewer_settings_for(self, person): @@ -484,6 +490,7 @@ def set_wants_to_be_next(self, reviewer_person): # Instead, the "assign me next" flag is set. settings = self._reviewer_settings_for(reviewer_person) settings.request_assignment_next = True + settings._change_reason = "Setting request next assignment" settings.save() def _update_skip_next(self, rotation_pks, assignee_person): @@ -523,20 +530,22 @@ def _update_skip_next(self, rotation_pks, assignee_person): min_skip_next = min([rs.skip_next for rs in rotation_settings.values()]) next_reviewer_index = None + changed = [] for index, pk in enumerate(unfolded_rotation_pks): rs = rotation_settings.get(pk) if (rs is None) or (rs.skip_next == min_skip_next): next_reviewer_index = index break else: - rs.skip_next = max(0, rs.skip_next - 1) # ensure never negative + if rs.skip_next > 0: + rs.skip_next = max(0, rs.skip_next - 1) # ensure never negative + rs._change_reason = "Skip count decremented" + changed.append(rs) log.assertion('next_reviewer_index is not None') # some entry in the list must have the minimum value - - bulk_update_with_history(rotation_settings.values(), + bulk_update_with_history(changed, ReviewerSettings, - ['skip_next'], - default_change_reason='skipped') + ['skip_next']) next_reviewer_pk = unfolded_rotation_pks[next_reviewer_index] NextReviewerInTeam.objects.update_or_create( @@ -578,6 +587,7 @@ def set_wants_to_be_next(self, reviewer_person): # who rejected a review and no further action is needed. settings = self._reviewer_settings_for(reviewer_person) settings.request_assignment_next = True + settings._change_reason = "Setting request next assignment" settings.save() diff --git a/ietf/review/tasks.py b/ietf/review/tasks.py new file mode 100644 index 0000000000..5d8afa6943 --- /dev/null +++ b/ietf/review/tasks.py @@ -0,0 +1,43 @@ +# Copyright The IETF Trust 2024, All Rights Reserved +# +# Celery task definitions +# +from celery import shared_task + +from ietf.review.utils import ( + review_assignments_needing_reviewer_reminder, email_reviewer_reminder, + review_assignments_needing_secretary_reminder, email_secretary_reminder, + send_unavailability_period_ending_reminder, send_reminder_all_open_reviews, + send_review_reminder_overdue_assignment, send_reminder_unconfirmed_assignments) +from ietf.utils.log import log +from ietf.utils.timezone import date_today, DEADLINE_TZINFO + + +@shared_task +def send_review_reminders_task(): + today = date_today(DEADLINE_TZINFO) + + for assignment in review_assignments_needing_reviewer_reminder(today): + email_reviewer_reminder(assignment) + log("Emailed reminder to {} for review of {} in {} (req. id {})".format(assignment.reviewer.address, assignment.review_request.doc_id, assignment.review_request.team.acronym, assignment.review_request.pk)) + + for assignment, secretary_role in review_assignments_needing_secretary_reminder(today): + email_secretary_reminder(assignment, secretary_role) + review_req = assignment.review_request + log("Emailed reminder to {} for review of {} in {} (req. id {})".format(secretary_role.email.address, review_req.doc_id, review_req.team.acronym, review_req.pk)) + + period_end_reminders_sent = send_unavailability_period_ending_reminder(today) + for msg in period_end_reminders_sent: + log(msg) + + overdue_reviews_reminders_sent = send_review_reminder_overdue_assignment(today) + for msg in overdue_reviews_reminders_sent: + log(msg) + + open_reviews_reminders_sent = send_reminder_all_open_reviews(today) + for msg in open_reviews_reminders_sent: + log(msg) + + unconfirmed_assignment_reminders_sent = send_reminder_unconfirmed_assignments(today) + for msg in unconfirmed_assignment_reminders_sent: + log(msg) diff --git a/ietf/review/tests.py b/ietf/review/tests.py index f9d55d9d14..e9ddbd47af 100644 --- a/ietf/review/tests.py +++ b/ietf/review/tests.py @@ -1,9 +1,11 @@ # Copyright The IETF Trust 2019-2020, All Rights Reserved # -*- coding: utf-8 -*- import datetime +import mock import debug # pyflakes:ignore from pyquery import PyQuery + from ietf.group.factories import RoleFactory from ietf.doc.factories import WgDraftFactory from ietf.utils.mail import empty_outbox, get_payload_text, outbox @@ -13,6 +15,7 @@ from .factories import ReviewAssignmentFactory, ReviewRequestFactory, ReviewerSettingsFactory from .mailarch import hash_list_message_id from .models import ReviewerSettings, ReviewSecretarySettings, ReviewTeamSettings, UnavailablePeriod +from .tasks import send_review_reminders_task from .utils import (email_secretary_reminder, review_assignments_needing_secretary_reminder, email_reviewer_reminder, review_assignments_needing_reviewer_reminder, send_reminder_unconfirmed_assignments, send_review_reminder_overdue_assignment, @@ -550,3 +553,66 @@ def test_review_add_comment(self): # But can't have the comment we are goint to add. self.assertContains(r, 'This is a test.') + +class TaskTests(TestCase): + # hyaaa it's mockzilla + @mock.patch("ietf.review.tasks.date_today") + @mock.patch("ietf.review.tasks.review_assignments_needing_reviewer_reminder") + @mock.patch("ietf.review.tasks.email_reviewer_reminder") + @mock.patch("ietf.review.tasks.review_assignments_needing_secretary_reminder") + @mock.patch("ietf.review.tasks.email_secretary_reminder") + @mock.patch("ietf.review.tasks.send_unavailability_period_ending_reminder") + @mock.patch("ietf.review.tasks.send_reminder_all_open_reviews") + @mock.patch("ietf.review.tasks.send_review_reminder_overdue_assignment") + @mock.patch("ietf.review.tasks.send_reminder_unconfirmed_assignments") + def test_send_review_reminders_task( + self, + mock_send_reminder_unconfirmed_assignments, + mock_send_review_reminder_overdue_assignment, + mock_send_reminder_all_open_reviews, + mock_send_unavailability_period_ending_reminder, + mock_email_secretary_reminder, + mock_review_assignments_needing_secretary_reminder, + mock_email_reviewer_reminder, + mock_review_assignments_needing_reviewer_reminder, + mock_date_today, + ): + """Test that send_review_reminders calls functions correctly + + Does not test individual methods, just that they are called as expected. + """ + mock_today = object() + assignment = ReviewAssignmentFactory() + secretary_role = RoleFactory(name_id="secr") + + mock_date_today.return_value = mock_today + mock_review_assignments_needing_reviewer_reminder.return_value = [assignment] + mock_review_assignments_needing_secretary_reminder.return_value = [[assignment, secretary_role]] + mock_send_unavailability_period_ending_reminder.return_value = ["pretending I sent a period end reminder"] + mock_send_review_reminder_overdue_assignment.return_value = ["pretending I sent an overdue reminder"] + mock_send_reminder_all_open_reviews.return_value = ["pretending I sent an open review reminder"] + mock_send_reminder_unconfirmed_assignments.return_value = ["pretending I sent an unconfirmed reminder"] + + send_review_reminders_task() + + self.assertEqual(mock_review_assignments_needing_reviewer_reminder.call_count, 1) + self.assertEqual(mock_review_assignments_needing_reviewer_reminder.call_args[0], (mock_today,)) + self.assertEqual(mock_email_reviewer_reminder.call_count, 1) + self.assertEqual(mock_email_reviewer_reminder.call_args[0], (assignment,)) + + self.assertEqual(mock_review_assignments_needing_secretary_reminder.call_count, 1) + self.assertEqual(mock_review_assignments_needing_secretary_reminder.call_args[0], (mock_today,)) + self.assertEqual(mock_email_secretary_reminder.call_count, 1) + self.assertEqual(mock_email_secretary_reminder.call_args[0], (assignment, secretary_role)) + + self.assertEqual(mock_send_unavailability_period_ending_reminder.call_count, 1) + self.assertEqual(mock_send_unavailability_period_ending_reminder.call_args[0], (mock_today,)) + + self.assertEqual(mock_send_review_reminder_overdue_assignment.call_count, 1) + self.assertEqual(mock_send_review_reminder_overdue_assignment.call_args[0], (mock_today,)) + + self.assertEqual(mock_send_reminder_all_open_reviews.call_count, 1) + self.assertEqual(mock_send_reminder_all_open_reviews.call_args[0], (mock_today,)) + + self.assertEqual(mock_send_reminder_unconfirmed_assignments.call_count, 1) + self.assertEqual(mock_send_reminder_unconfirmed_assignments.call_args[0], (mock_today,)) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 8869efaee0..61494738d3 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2016-2020, All Rights Reserved +# Copyright The IETF Trust 2016-2023, All Rights Reserved # -*- coding: utf-8 -*- @@ -392,7 +392,9 @@ def assign_review_request_to_reviewer(request, review_req, reviewer, add_skip=Fa # cannot reference reviewassignment_set relation until pk exists if review_req.pk is not None: reviewassignment_set = review_req.reviewassignment_set.filter(reviewer=reviewer) - if reviewassignment_set.exists() and not reviewassignment_set.filter(state_id='rejected').exists(): + if (reviewassignment_set.exists() and not + (reviewassignment_set.filter(state_id='rejected').exists() or + reviewassignment_set.filter(state_id='withdrawn').exists())): return # Note that assigning a review no longer unassigns other reviews diff --git a/ietf/secr/rolodex/views.py b/ietf/secr/rolodex/views.py index 7dd8201f0c..9fd4a8b107 100644 --- a/ietf/secr/rolodex/views.py +++ b/ietf/secr/rolodex/views.py @@ -7,7 +7,6 @@ from ietf.ietfauth.utils import role_required from ietf.person.models import Person, Email, Alias -from ietf.person.utils import merge_users from ietf.secr.rolodex.forms import EditPersonForm, EmailForm, NameForm, NewPersonForm, SearchForm @@ -179,7 +178,6 @@ def edit(request, id): if 'user' in person_form.changed_data and person_form.initial['user']: try: source = User.objects.get(username__iexact=person_form.initial['user']) - merge_users(source, person_form.cleaned_data['user']) source.is_active = False source.save() except User.DoesNotExist: diff --git a/ietf/secr/templates/includes/activities.html b/ietf/secr/templates/includes/activities.html index 3e79c9aed4..1304b7c48d 100644 --- a/ietf/secr/templates/includes/activities.html +++ b/ietf/secr/templates/includes/activities.html @@ -1,4 +1,4 @@ -

Activies Log

+

Activities Log